mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-20 11:57:15 +00:00
Compare commits
7 Commits
develop
...
codex/stoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d10b4135 | ||
|
|
6007b895dd | ||
|
|
0728f0cfc2 | ||
|
|
98af470327 | ||
|
|
c9c5f3c2db | ||
|
|
01e3032c2c | ||
|
|
1be56e63e9 |
7
.github/POSTGRES_COMPATIBILITY.md
vendored
7
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -180,13 +180,6 @@ audit of these fixes found four recurring mistakes:
|
||||
the arbitrary-pick preservation the wrap is usually justified as. Confirmed on CI; see #56241.
|
||||
Note a local macOS PostgreSQL gives a **false all-clear** — its collation happens to agree with
|
||||
MariaDB on case. Fix: take a representative row rather than sorting text.
|
||||
**Picking that row is the hard part.** `Min(name)` is still a text sort: `autoname="hash"` is
|
||||
not reliably lower case, because `_get_timestamp_prefix()` prepends `get_trace_id()[-1:]`
|
||||
un-lowered and a client-supplied `X-Frappe-Request-Id` can put an upper case `A-F` there. A
|
||||
non-text key (`Min(idx)`) works only where it is **unique within the group** and the join-back
|
||||
carries the **full group key** — a date is usually neither, and joining on a duplicated value
|
||||
turns one group into several rows (§3). Otherwise select the row in Python, sorting with
|
||||
`key=str.casefold` so the order matches MariaDB's collation without depending on the database's.
|
||||
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
|
||||
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
|
||||
average for a rate. A blind `Max` can understate urgency or overstate a figure.
|
||||
|
||||
@@ -55,18 +55,6 @@ class ERPNextAddress(Address):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_shipping_address(company: str, address: str | None = None):
|
||||
# `company` is caller supplied and this returns that company's own registered address with every
|
||||
# field. `select` rather than `read` on Company: Delivery, Maintenance, Purchase Manager and
|
||||
# Stock Manager all fill in transactions that ask for this while holding no Company `read` row.
|
||||
frappe.has_permission("Company", ptype="select", throw=True)
|
||||
|
||||
# and scope it to the caller's own Company restrictions, which costs nobody who has none
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Address")
|
||||
if allowed_companies and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
filters = [
|
||||
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||
["Dynamic Link", "link_name", "=", company],
|
||||
|
||||
@@ -24,7 +24,7 @@ def get(
|
||||
heatmap_year: str | None = None,
|
||||
):
|
||||
if chart_name:
|
||||
chart = frappe.get_doc("Dashboard Chart", chart_name, check_permission="read")
|
||||
chart = frappe.get_doc("Dashboard Chart", chart_name)
|
||||
else:
|
||||
chart = frappe._dict(frappe.parse_json(chart))
|
||||
timespan = chart.timespan
|
||||
@@ -46,9 +46,6 @@ def get(
|
||||
if not account:
|
||||
frappe.throw(_("Account filter not set!"))
|
||||
|
||||
# authorise the account itself, as get_balance_on() does; doc= brings User Permissions with it
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
if not to_date:
|
||||
to_date = nowdate()
|
||||
if not from_date:
|
||||
|
||||
@@ -503,21 +503,24 @@ class Account(NestedSet):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
return frappe.get_list(
|
||||
"Account",
|
||||
filters=[
|
||||
["is_group", "=", 1],
|
||||
["docstatus", "!=", 2],
|
||||
["company", "=", filters["company"]],
|
||||
[searchfield, "like", f"%{txt}%"],
|
||||
],
|
||||
fields=["name"],
|
||||
order_by="name",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
Account = frappe.qb.DocType("Account")
|
||||
|
||||
search_field_obj = getattr(Account, searchfield)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(Account)
|
||||
.select(Account.name)
|
||||
.where(Account.is_group == 1)
|
||||
.where(Account.docstatus != 2)
|
||||
.where(Account.company == filters["company"])
|
||||
.where(search_field_obj.like(f"%{txt}%"))
|
||||
.order_by(Account.name)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
|
||||
return query.run(as_list=1)
|
||||
|
||||
|
||||
def get_account_currency(account):
|
||||
"""Helper function to get account currency"""
|
||||
|
||||
@@ -223,11 +223,8 @@ def delete_accounting_dimension(doc):
|
||||
frappe.clear_cache(doctype=doctype)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def disable_dimension(doc: str):
|
||||
# toggle_disabling rewrites a Custom Field site-wide, so demand the write that configures dimensions
|
||||
frappe.has_permission("Accounting Dimension", "write", throw=True)
|
||||
|
||||
if frappe.in_test:
|
||||
toggle_disabling(doc=doc)
|
||||
else:
|
||||
|
||||
@@ -60,9 +60,6 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
|
||||
if not isinstance(reference_name, str):
|
||||
raise TypeError("reference_name must be a string")
|
||||
|
||||
# the form is the boundary, not the referenced order: an order guard would break one of the two roles
|
||||
frappe.has_permission("Bank Guarantee", throw=True)
|
||||
|
||||
fields_to_fetch = ["grand_total"]
|
||||
|
||||
if bank_guarantee_type == "Receiving":
|
||||
@@ -73,14 +70,4 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
|
||||
doctype = "Purchase Order"
|
||||
fields_to_fetch.append("supplier")
|
||||
|
||||
# and scope the referenced order to the caller's own Company restrictions, which costs nobody
|
||||
# who has none
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Bank Guarantee")
|
||||
if allowed_companies:
|
||||
company = frappe.db.get_value(doctype, reference_name, "company")
|
||||
if company and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
return frappe.db.get_value(doctype, reference_name, fields_to_fetch, as_dict=True)
|
||||
|
||||
@@ -68,7 +68,6 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
frappe.msgprint(__("Please select Bank Account"));
|
||||
return;
|
||||
}
|
||||
frm.events.validate_dates(frm);
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.auto_reconcile_vouchers",
|
||||
args: {
|
||||
@@ -83,7 +82,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
});
|
||||
|
||||
frm.add_custom_button(__("Get Unreconciled Entries"), function () {
|
||||
return frm.trigger("make_reconciliation_tool");
|
||||
frm.trigger("make_reconciliation_tool");
|
||||
});
|
||||
frm.change_custom_button_type(__("Get Unreconciled Entries"), null, "primary");
|
||||
|
||||
@@ -107,24 +106,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
frm.trigger("get_account_opening_balance");
|
||||
},
|
||||
|
||||
validate_dates(frm) {
|
||||
const from_date = frm.doc.filter_by_reference_date
|
||||
? frm.doc.from_reference_date
|
||||
: frm.doc.bank_statement_from_date;
|
||||
const to_date = frm.doc.filter_by_reference_date
|
||||
? frm.doc.to_reference_date
|
||||
: frm.doc.bank_statement_to_date;
|
||||
if (from_date && to_date && from_date > to_date) {
|
||||
frappe.throw(
|
||||
frm.doc.filter_by_reference_date
|
||||
? __("From Reference Date cannot be greater than To Reference Date")
|
||||
: __("From Date cannot be greater than To Date")
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
make_reconciliation_tool(frm) {
|
||||
frm.events.validate_dates(frm);
|
||||
frm.get_field("reconciliation_tool_cards").$wrapper.empty();
|
||||
if (frm.doc.company && frm.doc.bank_account && frm.doc.bank_statement_to_date) {
|
||||
frm.trigger("get_cleared_balance").then(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import cint, create_batch, flt, getdate
|
||||
from frappe.utils import cint, create_batch, flt
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
|
||||
@@ -54,8 +54,6 @@ def get_bank_transactions(
|
||||
all_transactions: bool = False,
|
||||
):
|
||||
# returns bank transactions for a bank account
|
||||
validate_date_range(from_date, to_date)
|
||||
|
||||
filters = []
|
||||
filters.append(["bank_account", "=", bank_account])
|
||||
filters.append(["docstatus", "=", 1])
|
||||
@@ -964,10 +962,9 @@ def auto_reconcile_vouchers(
|
||||
from_date: str | date | None = None,
|
||||
to_date: str | date | None = None,
|
||||
filter_by_reference_date: bool | None = None,
|
||||
from_reference_date: str | date | None = None,
|
||||
to_reference_date: str | date | None = None,
|
||||
from_reference_date: bool | None = None,
|
||||
to_reference_date: str | None = None,
|
||||
):
|
||||
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
|
||||
bank_transactions = get_bank_transactions(bank_account)
|
||||
|
||||
if len(bank_transactions) > 10:
|
||||
@@ -1082,11 +1079,10 @@ def get_linked_payments(
|
||||
from_date: str | date | None = None,
|
||||
to_date: str | date | None = None,
|
||||
filter_by_reference_date: bool | None = None,
|
||||
from_reference_date: str | date | None = None,
|
||||
to_reference_date: str | date | None = None,
|
||||
from_reference_date: bool | None = None,
|
||||
to_reference_date: str | None = None,
|
||||
):
|
||||
# get all matching payments for a bank transaction
|
||||
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
|
||||
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||
bank_account = frappe.db.get_values(
|
||||
"Bank Account", transaction.bank_account, ["account", "company"], as_dict=True
|
||||
@@ -1106,23 +1102,6 @@ def get_linked_payments(
|
||||
return subtract_allocations(gl_account, matching)
|
||||
|
||||
|
||||
def validate_date_range(
|
||||
from_date,
|
||||
to_date,
|
||||
filter_by_reference_date=False,
|
||||
from_reference_date=None,
|
||||
to_reference_date=None,
|
||||
):
|
||||
if cint(filter_by_reference_date):
|
||||
from_date, to_date = from_reference_date, to_reference_date
|
||||
message = _("From Reference Date cannot be greater than To Reference Date")
|
||||
else:
|
||||
message = _("From Date cannot be greater than To Date")
|
||||
|
||||
if from_date and to_date and getdate(from_date) > getdate(to_date):
|
||||
frappe.throw(message)
|
||||
|
||||
|
||||
def subtract_allocations(gl_account, vouchers):
|
||||
"Look up & subtract any existing Bank Transaction allocations"
|
||||
copied = []
|
||||
@@ -1159,7 +1138,6 @@ def check_matching(
|
||||
from_reference_date=None,
|
||||
to_reference_date=None,
|
||||
):
|
||||
document_types = document_types or []
|
||||
exact_match = True if "exact_match" in document_types else False
|
||||
|
||||
common_filters = frappe._dict(
|
||||
|
||||
@@ -131,37 +131,6 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
|
||||
self.assertEqual(names, [])
|
||||
|
||||
def test_get_linked_payments_without_document_types(self):
|
||||
bank_transaction = self.make_bank_transaction(date=today())
|
||||
self.assertEqual(get_linked_payments(bank_transaction.name), [])
|
||||
|
||||
def test_rejects_reversed_date_ranges(self):
|
||||
from_date, to_date = today(), add_days(today(), -1)
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "From Date cannot be greater than To Date"):
|
||||
get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
|
||||
):
|
||||
auto_reconcile_vouchers(
|
||||
self.bank_account,
|
||||
filter_by_reference_date=True,
|
||||
from_reference_date=from_date,
|
||||
to_reference_date=to_date,
|
||||
)
|
||||
|
||||
transaction = self.make_bank_transaction(date=today())
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
|
||||
):
|
||||
get_linked_payments(
|
||||
transaction.name,
|
||||
["payment_entry"],
|
||||
filter_by_reference_date=True,
|
||||
from_reference_date=from_date,
|
||||
to_reference_date=to_date,
|
||||
)
|
||||
|
||||
def test_deposit_matches_amount_received_in_bank_account(self):
|
||||
# money leaves another bank account and lands here minus a charge, so the two sides differ
|
||||
payment = frappe.get_doc(
|
||||
|
||||
@@ -437,11 +437,6 @@ def get_import_logs(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_bank_statement(**args):
|
||||
# The only caller is the Bank Reconciliation Tool's "Upload Bank Statement" button, whose
|
||||
# callback routes straight into a new Bank Statement Import form — so `create` is exactly the
|
||||
# right to require, and both doctypes are System Manager only, which makes it loser-free.
|
||||
frappe.has_permission("Bank Statement Import", "create", throw=True)
|
||||
|
||||
args = frappe._dict(args)
|
||||
bsi = frappe.new_doc("Bank Statement Import")
|
||||
|
||||
|
||||
@@ -11,11 +11,6 @@ from frappe.utils.dateutils import parse_date
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_bank_statement():
|
||||
# Parsing a statement is the first step of creating Bank Transactions from it, so that is the
|
||||
# right to require. Both functions in this file are reached only over HTTP — nothing in the tree
|
||||
# calls either — so there is no caller to break.
|
||||
frappe.has_permission("Bank Transaction", "create", throw=True)
|
||||
|
||||
if getattr(frappe, "uploaded_file", None):
|
||||
with open(frappe.uploaded_file, "rb") as upfile:
|
||||
fcontent = upfile.read()
|
||||
@@ -41,12 +36,6 @@ def upload_bank_statement():
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
# insert()/submit() below already enforce this per document, but only after the per-row loop has
|
||||
# read the Bank Account and its Bank mapping and written an Error Log for every rejected row —
|
||||
# so check once up front rather than failing row by row.
|
||||
frappe.has_permission("Bank Transaction", "create", throw=True)
|
||||
frappe.has_permission("Bank Account", doc=bank_account, throw=True)
|
||||
|
||||
header_map = get_header_mapping(columns, bank_account)
|
||||
|
||||
success = 0
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import ast
|
||||
import json
|
||||
import math
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cache, reduce
|
||||
@@ -28,7 +29,6 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_templat
|
||||
FinancialReportTemplate,
|
||||
)
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
FORMULA_FUNCTIONS,
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
DependencyValidator,
|
||||
@@ -1328,14 +1328,26 @@ class FormulaCalculator:
|
||||
self.precision = get_currency_precision()
|
||||
self.validator = CalculationFormulaValidator(set(row_data.keys()))
|
||||
|
||||
self.math_functions = {
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sqrt": math.sqrt,
|
||||
"pow": math.pow,
|
||||
"ceil": math.ceil,
|
||||
"floor": math.floor,
|
||||
}
|
||||
|
||||
def evaluate_formula(self, report_row: dict[str, Any]) -> list[float]:
|
||||
validation_result = self.validator.validate(report_row)
|
||||
formula = (report_row.calculation_formula or "").strip()
|
||||
formula = report_row.calculation_formula
|
||||
negation_factor = -1 if report_row.reverse_sign else 1
|
||||
|
||||
if validation_result.issues:
|
||||
# TODO: Throw?
|
||||
messages = "<br><br>".join(str(issue) for issue in validation_result.issues)
|
||||
messages = "<br><br>".join(issue.message for issue in validation_result.issues)
|
||||
frappe.log_error(f"Formula validation errors found:\n{messages}")
|
||||
return [0.0] * len(self.period_list)
|
||||
|
||||
@@ -1350,7 +1362,7 @@ class FormulaCalculator:
|
||||
# TODO: consistent error handling
|
||||
try:
|
||||
context = self._build_context(period_index)
|
||||
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
|
||||
result = frappe.safe_eval(formula, context)
|
||||
return flt(result * negation_factor, self.precision)
|
||||
|
||||
except ZeroDivisionError:
|
||||
@@ -1371,7 +1383,7 @@ class FormulaCalculator:
|
||||
context[code] = 0.0
|
||||
|
||||
# math functions
|
||||
context.update(FORMULA_FUNCTIONS)
|
||||
context.update(self.math_functions)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -34,13 +34,6 @@ class FinancialReportTemplate(Document):
|
||||
def before_validate(self):
|
||||
self.clear_hidden_fields()
|
||||
|
||||
for row in self.rows:
|
||||
if row.reference_code:
|
||||
row.reference_code = row.reference_code.strip()
|
||||
|
||||
if row.calculation_formula:
|
||||
row.calculation_formula = row.calculation_formula.strip()
|
||||
|
||||
def clear_hidden_fields(self):
|
||||
style_data_sources = {"Blank Line", "Column Break", "Section Break"}
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import json
|
||||
import keyword
|
||||
import math
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
@@ -12,19 +10,6 @@ from typing import Any
|
||||
import frappe
|
||||
from frappe import _, is_whitelisted
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
from frappe.utils import escape_html
|
||||
|
||||
FORMULA_FUNCTIONS = {
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sqrt": math.sqrt,
|
||||
"pow": math.pow,
|
||||
"ceil": math.ceil,
|
||||
"floor": math.floor,
|
||||
}
|
||||
|
||||
|
||||
def get_valid_api_method(api_path: str):
|
||||
@@ -104,9 +89,8 @@ class ValidationResult:
|
||||
self.warnings.append(issue)
|
||||
|
||||
def notify_user(self) -> None:
|
||||
# messages quote user input back, and both are rendered as HTML
|
||||
warnings = "<br><br>".join(escape_html(str(w)) for w in self.warnings if w)
|
||||
errors = "<br><br>".join(escape_html(str(e)) for e in self.issues if e)
|
||||
warnings = "<br><br>".join(str(w) for w in self.warnings if w)
|
||||
errors = "<br><br>".join(str(e) for e in self.issues if e)
|
||||
|
||||
if warnings:
|
||||
frappe.msgprint(warnings, title=_("Warnings"), indicator="orange")
|
||||
@@ -163,27 +147,18 @@ class TemplateStructureValidator(Validator):
|
||||
if not row.reference_code:
|
||||
continue
|
||||
|
||||
ref_code = row.reference_code
|
||||
ref_code = row.reference_code.strip()
|
||||
|
||||
# a line reference is used as a name in formulas, so it must be a usable one
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", ref_code):
|
||||
# Check format
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_(
|
||||
"Invalid line reference format: '{0}'. Must start with a letter and contain only letters, numbers and underscores"
|
||||
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
|
||||
).format(ref_code),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
elif keyword.iskeyword(ref_code) or ref_code in FORMULA_FUNCTIONS:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("'{0}' is a reserved name and cannot be used as a line reference").format(
|
||||
ref_code
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
|
||||
# Check uniqueness
|
||||
if ref_code in used_codes:
|
||||
@@ -233,7 +208,12 @@ class DependencyValidator(Validator):
|
||||
self.dependencies = self._build_dependency_graph()
|
||||
|
||||
def validate(self, context=None) -> ValidationResult:
|
||||
return self._validate_circular_dependencies()
|
||||
result = ValidationResult()
|
||||
|
||||
result.merge(self._validate_circular_dependencies())
|
||||
result.merge(self._validate_missing_dependencies())
|
||||
|
||||
return result
|
||||
|
||||
def _build_dependency_graph(self) -> dict[str, list[str]]:
|
||||
graph = {}
|
||||
@@ -300,6 +280,31 @@ class DependencyValidator(Validator):
|
||||
|
||||
return result
|
||||
|
||||
def _validate_missing_dependencies(self) -> ValidationResult:
|
||||
available = {row.reference_code for row in self.template.rows if row.reference_code}
|
||||
result = ValidationResult()
|
||||
|
||||
for ref_code, deps in self.dependencies.items():
|
||||
undefined = [d for d in deps if d not in available]
|
||||
if undefined:
|
||||
row_idx = self._get_row_idx(ref_code)
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Line references undefined in {0}: {1}").format(
|
||||
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
|
||||
),
|
||||
row_idx=row_idx,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _get_row_idx(self, reference_code: str) -> int | None:
|
||||
for row in self.template.rows:
|
||||
if row.reference_code == reference_code:
|
||||
return row.idx
|
||||
return None
|
||||
|
||||
|
||||
class CalculationFormulaValidator(Validator):
|
||||
"""Validates calculation formulas used in Calculated Amount rows"""
|
||||
@@ -315,6 +320,7 @@ class CalculationFormulaValidator(Validator):
|
||||
return result
|
||||
|
||||
formula = self._preprocess_formula(row.calculation_formula)
|
||||
row.calculation_formula = formula
|
||||
|
||||
# Check parentheses
|
||||
if not self._are_parentheses_balanced(formula):
|
||||
@@ -362,15 +368,25 @@ class CalculationFormulaValidator(Validator):
|
||||
def _test_formula_evaluation(self, formula: str, available_codes: list[str]) -> str | None:
|
||||
try:
|
||||
context = {code: 1.0 for code in available_codes}
|
||||
context.update(FORMULA_FUNCTIONS)
|
||||
context.update(
|
||||
{
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sqrt": lambda x: x**0.5,
|
||||
"pow": pow,
|
||||
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
|
||||
"floor": int,
|
||||
}
|
||||
)
|
||||
|
||||
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
|
||||
|
||||
if not isinstance(result, (int | float)):
|
||||
if not isinstance(result, (int, float)): # noqa: UP038
|
||||
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
|
||||
|
||||
return None
|
||||
except ZeroDivisionError:
|
||||
return None
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
@@ -446,14 +462,13 @@ class AccountFilterValidator(Validator):
|
||||
return _("Field and operator must be strings")
|
||||
|
||||
if field not in account_fields:
|
||||
return _("Field '{0}' is not a valid Account field").format(field)
|
||||
# escape: `field` is caller-supplied and this message renders as HTML
|
||||
return _("Field '{0}' is not a valid Account field").format(frappe.utils.escape_html(field))
|
||||
|
||||
normalized_operator = operator.casefold()
|
||||
|
||||
if normalized_operator not in OPERATOR_MAP:
|
||||
if operator.casefold() not in OPERATOR_MAP:
|
||||
return _("Invalid operator '{0}'").format(operator)
|
||||
|
||||
if normalized_operator in ["in", "not in"] and not isinstance(value, list):
|
||||
if operator in ["in", "not in"] and not isinstance(value, list):
|
||||
return _("Operator '{0}' requires a list value").format(operator)
|
||||
|
||||
# logical condition: {"and": [condition1, condition2]}
|
||||
|
||||
@@ -5,11 +5,8 @@ import frappe
|
||||
from frappe.tests.utils import whitelist_for_tests
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
FORMULA_FUNCTIONS,
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
FormulaValidator,
|
||||
TemplateStructureValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -192,13 +189,8 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
|
||||
def test_error_message_labels_and_escapes_field(self):
|
||||
validator = AccountFilterValidator()
|
||||
result = validator.validate_filter(self._row('["<script>", "=", "x"]'))
|
||||
self.assertIn("[Account Filter]", str(result.issues[0]))
|
||||
|
||||
# escaping happens where the message is rendered, not where it is built
|
||||
frappe.clear_messages()
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
result.notify_user()
|
||||
message = frappe.get_message_log()[-1]["message"]
|
||||
message = str(result.issues[0])
|
||||
self.assertIn("[Account Filter]", message)
|
||||
self.assertIn("<script>", message)
|
||||
self.assertNotIn("<script>", message)
|
||||
|
||||
@@ -256,149 +248,3 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
|
||||
pluck="name",
|
||||
)
|
||||
self.assertEqual(sorted(get_filtered_accounts(company, "[]")), sorted(expected))
|
||||
|
||||
|
||||
class TestFormulaEnvironment(FinancialReportTemplateTestCase):
|
||||
"""Validator and engine must evaluate a formula in the same environment."""
|
||||
|
||||
@staticmethod
|
||||
def _calc(row_data):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FormulaCalculator,
|
||||
)
|
||||
|
||||
return FormulaCalculator(row_data, [{"key": "p1"}])
|
||||
|
||||
@staticmethod
|
||||
def _row(formula):
|
||||
return frappe._dict(
|
||||
calculation_formula=formula,
|
||||
idx=1,
|
||||
reverse_sign=0,
|
||||
data_source="Calculated Amount",
|
||||
reference_code="X",
|
||||
)
|
||||
|
||||
def test_engine_keeps_reference_codes_named_like_builtins(self):
|
||||
# "int" and "long" are whitelisted safe_eval globals; the row values must win
|
||||
calc = self._calc({"int": [500.0], "long": [2000.0]})
|
||||
self.assertEqual(calc.evaluate_formula(self._row("int + long"))[0], 2500.0)
|
||||
|
||||
def test_validator_keeps_reference_codes_named_like_builtins(self):
|
||||
validator = CalculationFormulaValidator({"int", "long"})
|
||||
self.assertTrue(validator.validate(self._row("int + long")).is_valid)
|
||||
|
||||
def test_engine_uses_the_shared_function_list(self):
|
||||
context = self._calc({"A": [1.0]})._build_context(0)
|
||||
for name, function in FORMULA_FUNCTIONS.items():
|
||||
self.assertIs(context[name], function)
|
||||
|
||||
def test_rounding_matches_math_module(self):
|
||||
calc = self._calc({"A": [1.0]})
|
||||
self.assertEqual(calc.evaluate_formula(self._row("floor(-2.5)"))[0], -3.0)
|
||||
self.assertEqual(calc.evaluate_formula(self._row("ceil(-2.5)"))[0], -2.0)
|
||||
|
||||
|
||||
class TestCalculationFormula(FinancialReportTemplateTestCase):
|
||||
"""Formulas are test-evaluated with dummy values before a template can be saved."""
|
||||
|
||||
@staticmethod
|
||||
def _validate(formula, codes=("A", "B", "C")):
|
||||
row = frappe._dict(
|
||||
calculation_formula=formula, idx=1, data_source="Calculated Amount", reference_code="X"
|
||||
)
|
||||
return CalculationFormulaValidator(set(codes)).validate(row)
|
||||
|
||||
def test_division_by_zero_is_not_a_validation_error(self):
|
||||
# the dummy values are all 1.0, so a denominator can only be zero by accident;
|
||||
# the engine tolerates real division by zero at run time
|
||||
self.assertTrue(self._validate("A / (B - C)").is_valid)
|
||||
self.assertTrue(self._validate("(A - B) / (A - C)").is_valid)
|
||||
self.assertTrue(self._validate("ROM / (CAS + FDE - ROM)", ("ROM", "CAS", "FDE")).is_valid)
|
||||
self.assertTrue(self._validate("A / 0").is_valid)
|
||||
|
||||
def test_broken_formulas_are_rejected(self):
|
||||
self.assertFalse(self._validate("A +").is_valid)
|
||||
self.assertFalse(self._validate("NOPE * 2").is_valid)
|
||||
self.assertFalse(self._validate("'text'").is_valid)
|
||||
|
||||
|
||||
class TestFilterOperatorCase(FinancialReportTemplateTestCase):
|
||||
"""Operators are matched case-insensitively, so their value checks must be too."""
|
||||
|
||||
@staticmethod
|
||||
def _row(formula):
|
||||
return frappe._dict(calculation_formula=formula, idx=1)
|
||||
|
||||
def test_uppercase_in_requires_a_list_value(self):
|
||||
validator = AccountFilterValidator()
|
||||
self.assertFalse(validator.validate_filter(self._row('["root_type", "IN", "Income"]')).is_valid)
|
||||
self.assertFalse(validator.validate_filter(self._row('["root_type", "NOT IN", "Income"]')).is_valid)
|
||||
|
||||
def test_uppercase_in_accepts_a_list_value(self):
|
||||
validator = AccountFilterValidator()
|
||||
self.assertTrue(validator.validate_filter(self._row('["root_type", "IN", ["Income"]]')).is_valid)
|
||||
|
||||
|
||||
class TestLineReferenceNames(FinancialReportTemplateTestCase):
|
||||
"""A line reference becomes a name in formulas, so it must be usable as one."""
|
||||
|
||||
@staticmethod
|
||||
def _validate(code):
|
||||
template = frappe._dict(rows=[frappe._dict(reference_code=code, idx=1, data_source="Blank Line")])
|
||||
return TemplateStructureValidator()._validate_reference_codes(template)
|
||||
|
||||
def test_plain_codes_are_accepted(self):
|
||||
for code in ("REV", "CA100", "cash_flow_2"):
|
||||
self.assertTrue(self._validate(code).is_valid, code)
|
||||
|
||||
def test_hyphen_is_rejected(self):
|
||||
# "-" reads as subtraction in a formula and is not a valid Python name
|
||||
self.assertFalse(self._validate("REV-COGS").is_valid)
|
||||
|
||||
def test_python_keyword_is_rejected(self):
|
||||
for code in ("if", "None", "class"):
|
||||
self.assertFalse(self._validate(code).is_valid, code)
|
||||
|
||||
def test_formula_function_name_is_rejected(self):
|
||||
# these would be overwritten by the function of the same name
|
||||
for code in ("sum", "round", "abs"):
|
||||
self.assertFalse(self._validate(code).is_valid, code)
|
||||
|
||||
def test_surrounding_spaces_are_normalised_before_validation(self):
|
||||
template = frappe.new_doc("Financial Report Template")
|
||||
template.template_name = "Spaces"
|
||||
template.append("rows", {"reference_code": " REV ", "data_source": "Blank Line"})
|
||||
template.append(
|
||||
"rows",
|
||||
{
|
||||
"reference_code": "X",
|
||||
"data_source": "Calculated Amount",
|
||||
"calculation_formula": " REV * 2 ",
|
||||
},
|
||||
)
|
||||
template.before_validate()
|
||||
self.assertEqual(template.rows[0].reference_code, "REV")
|
||||
self.assertEqual(template.rows[1].calculation_formula, "REV * 2")
|
||||
|
||||
def test_validation_does_not_modify_the_row(self):
|
||||
row = frappe._dict(
|
||||
calculation_formula=" REV * 2 ",
|
||||
idx=1,
|
||||
data_source="Calculated Amount",
|
||||
reference_code="X",
|
||||
)
|
||||
CalculationFormulaValidator({"REV", "X"}).validate(row)
|
||||
self.assertEqual(row.calculation_formula, " REV * 2 ")
|
||||
|
||||
def test_invalid_reference_code_is_escaped(self):
|
||||
# this message fires when the code fails the format check, so it can hold anything
|
||||
template = frappe._dict(rows=[frappe._dict(reference_code="<img src=x onerror=alert(1)>", idx=1)])
|
||||
result = TemplateStructureValidator()._validate_reference_codes(template)
|
||||
|
||||
frappe.clear_messages()
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
result.notify_user()
|
||||
message = frappe.get_message_log()[-1]["message"]
|
||||
self.assertIn("<img", message)
|
||||
self.assertNotIn("<img", message)
|
||||
|
||||
@@ -1023,11 +1023,6 @@ def get_default_bank_cash_account(
|
||||
) -> dict:
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
|
||||
|
||||
# the company is the scope being authorised, and doc= brings User Permissions to bear. `select`,
|
||||
# not `read`: this also runs server-side from get_payment_entry, and Auditor/HR User/Desk User
|
||||
# hold only the select row on Company
|
||||
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
|
||||
|
||||
if mode_of_payment:
|
||||
account = get_bank_cash_account(mode_of_payment, company).get("account")
|
||||
|
||||
@@ -1056,11 +1051,6 @@ def get_default_bank_cash_account(
|
||||
account = account_list[0].name
|
||||
|
||||
if account:
|
||||
# `account` may be named by the caller outright, so authorise the account actually being
|
||||
# described. get_balance_on() checks this too, but only on the branch that reads a balance,
|
||||
# and `fetch_balance` is a caller-supplied argument.
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
account_details = frappe.get_cached_value(
|
||||
"Account", account, ["account_currency", "account_type"], as_dict=1
|
||||
)
|
||||
@@ -1090,40 +1080,30 @@ def get_against_jv(
|
||||
if not frappe.db.has_column("Journal Entry", searchfield):
|
||||
return []
|
||||
|
||||
account = filters.get("account")
|
||||
JournalEntry = frappe.qb.DocType("Journal Entry")
|
||||
JournalEntryAccount = frappe.qb.DocType("Journal Entry Account")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(JournalEntry)
|
||||
.join(JournalEntryAccount)
|
||||
.on(JournalEntryAccount.parent == JournalEntry.name)
|
||||
.select(JournalEntry.name, JournalEntry.posting_date, JournalEntry.remark)
|
||||
.where(JournalEntryAccount.account == filters.get("account"))
|
||||
.where(JournalEntryAccount.reference_type.isnull() | (JournalEntryAccount.reference_type == ""))
|
||||
.where(JournalEntry.docstatus == 1)
|
||||
.where(JournalEntry[searchfield].like(f"%{txt}%"))
|
||||
.orderby(JournalEntry.name, order=frappe.qb.desc)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
|
||||
party = filters.get("party")
|
||||
if party:
|
||||
query = query.where(JournalEntryAccount.party == party)
|
||||
else:
|
||||
query = query.where(JournalEntryAccount.party.isnull() | (JournalEntryAccount.party == ""))
|
||||
|
||||
# each names one value. A list would be read as a filter operator below and widen the search
|
||||
# past what the caller named.
|
||||
for value in (account, party):
|
||||
if value and not isinstance(value, str):
|
||||
frappe.throw(_("Invalid filter"), frappe.PermissionError)
|
||||
|
||||
# get_list applies the permission query conditions; the child-table filter resolves the check to `read`
|
||||
je_filters = [
|
||||
["docstatus", "=", 1],
|
||||
[searchfield, "like", f"%{txt}%"],
|
||||
["Journal Entry Account", "account", "=", account],
|
||||
["Journal Entry Account", "reference_type", "is", "not set"],
|
||||
]
|
||||
je_filters.append(
|
||||
["Journal Entry Account", "party", "=", party]
|
||||
if party
|
||||
else ["Journal Entry Account", "party", "is", "not set"]
|
||||
)
|
||||
|
||||
return frappe.get_list(
|
||||
"Journal Entry",
|
||||
filters=je_filters,
|
||||
fields=["name", "posting_date", "remark"],
|
||||
order_by="name desc",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
# one row per entry, not per matching account row. group_by rather than distinct: frappe
|
||||
# drops ORDER BY from a distinct query on postgres, which would lose the ordering above.
|
||||
group_by="name",
|
||||
)
|
||||
return query.run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -129,11 +129,6 @@ def get_loyalty_program_details(
|
||||
silent: bool = False,
|
||||
include_expired_entry: bool = False,
|
||||
):
|
||||
# Same guard as get_loyalty_program_details_with_points above: the customer is what the caller
|
||||
# is entitled to, not the programme. A check on Loyalty Program itself would be read-only to
|
||||
# System Manager and would deny every role that actually fills in the two calling forms.
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
lp_details = frappe._dict()
|
||||
|
||||
if not loyalty_program:
|
||||
@@ -155,13 +150,6 @@ def get_loyalty_program_details(
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_redeemption_factor(loyalty_program: str | None = None, customer: str | None = None):
|
||||
# both call sites send only `loyalty_program`, so the calling form is the boundary; the customer branch stays guarded
|
||||
if not (frappe.has_permission("Sales Invoice") or frappe.has_permission("POS Invoice")):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
if customer:
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
customer_loyalty_program = None
|
||||
if not loyalty_program:
|
||||
customer_loyalty_program = frappe.db.get_value("Customer", customer, "loyalty_program")
|
||||
|
||||
@@ -57,27 +57,9 @@ class PaymentOrder(Document):
|
||||
frappe.db.set_value(self.payment_order_type, d.get(ref_doc_field), ref_field, status)
|
||||
|
||||
|
||||
def _readable_payment_order(filters: dict) -> str | None:
|
||||
"""Authorise the parent before reading its rows.
|
||||
|
||||
A child table carries no permissions of its own, so a read of it has to be authorised on the
|
||||
Payment Order the rows belong to.
|
||||
"""
|
||||
parent = filters.get("parent")
|
||||
if not parent or not frappe.db.exists("Payment Order", parent):
|
||||
return None
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm("Payment Order") else "read"
|
||||
frappe.has_permission("Payment Order", ptype, doc=parent, throw=True)
|
||||
return parent
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
if not _readable_payment_order(filters):
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Payment Order Reference",
|
||||
filters={"parent": filters.get("parent"), "mode_of_payment": ["like", f"%{txt}%"]},
|
||||
@@ -92,9 +74,6 @@ def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
if not _readable_payment_order(filters):
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Payment Order Reference",
|
||||
filters={
|
||||
|
||||
@@ -256,13 +256,6 @@ class POSClosingEntry(StatusUpdater):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_cashiers(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
pos_profile = filters.get("parent")
|
||||
if not pos_profile or not frappe.db.exists("POS Profile", pos_profile):
|
||||
return []
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm("POS Profile") else "read"
|
||||
frappe.has_permission("POS Profile", ptype, doc=pos_profile, throw=True)
|
||||
|
||||
cashiers_list = frappe.get_all("POS Profile User", filters=filters, fields=["user"], as_list=1)
|
||||
return [c for c in cashiers_list]
|
||||
|
||||
|
||||
@@ -909,30 +909,6 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
# The POS Profile is what entitles a caller to POS stock figures, and it is the only boundary
|
||||
# that fits: `Item` read and `Bin` read both exclude Accounts Manager, `Item` select is granted
|
||||
# to every desk user by `Desk User`, and `POS Invoice` read is granted to `All`.
|
||||
frappe.has_permission("POS Profile", throw=True)
|
||||
|
||||
# and keep a company-restricted caller inside their own companies, which costs nobody who has
|
||||
# no Company User Permission
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "POS Profile")
|
||||
if allowed_companies:
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
if company and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
# the caller picks the warehouse when allow_warehouse_change is set, and the company check above
|
||||
# does not narrow within a company; costs nobody who has no Warehouse User Permission
|
||||
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
|
||||
|
||||
if warehouse_permissions := get_user_permissions(frappe.session.user).get("Warehouse"):
|
||||
allowed_warehouses = get_allowed_docs_for_doctype(warehouse_permissions, "POS Invoice")
|
||||
if allowed_warehouses and warehouse not in allowed_warehouses:
|
||||
frappe.throw(_("Not permitted for {0}").format(warehouse), frappe.PermissionError)
|
||||
|
||||
if frappe.db.get_value("Item", item_code, "is_stock_item"):
|
||||
is_stock_item = True
|
||||
bin_qty = get_bin_qty(item_code, warehouse)
|
||||
|
||||
@@ -108,7 +108,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
</td></tr>
|
||||
</table>`;
|
||||
|
||||
frm.get_field("pricing_rule_help").html(help_content);
|
||||
frm.set_df_property("pricing_rule_help", "options", help_content);
|
||||
frm.events.set_options_for_applicable_for(frm);
|
||||
frm.trigger("toggle_reqd_apply_on");
|
||||
},
|
||||
|
||||
@@ -12,29 +12,6 @@ from frappe import _, throw
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
# the transactions the pricing engine is called for, from transaction.js and the POS
|
||||
PRICING_TRANSACTION_DOCTYPES = frozenset(
|
||||
{
|
||||
"Quotation",
|
||||
"Sales Order",
|
||||
"Delivery Note",
|
||||
"Sales Invoice",
|
||||
"POS Invoice",
|
||||
"Supplier Quotation",
|
||||
"Purchase Order",
|
||||
"Purchase Receipt",
|
||||
"Purchase Invoice",
|
||||
"Material Request",
|
||||
# these three also extend a controller that calls the pricing engine: BOM and BOM Creator
|
||||
# through TransactionController, Request for Quotation through BuyingController
|
||||
"BOM",
|
||||
"BOM Creator",
|
||||
"Request for Quotation",
|
||||
# no client sends this one, but set_transaction_type below still branches on it
|
||||
"Opportunity",
|
||||
}
|
||||
)
|
||||
|
||||
apply_on_dict = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}
|
||||
|
||||
other_fields = ["other_item_code", "other_item_group", "other_brand"]
|
||||
@@ -389,30 +366,6 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non
|
||||
|
||||
args = frappe._dict(args)
|
||||
|
||||
# `args` is caller supplied, and what comes back is pricing: matched Pricing Rules, discounts
|
||||
# and rates. The transaction being priced is what decides who may price it, so authorise that
|
||||
# — and the document itself where the caller named an existing one, so User Permissions apply.
|
||||
# an allow-list, not just a type check: `doctype` is caller-chosen, and any doctype the caller can
|
||||
# read would otherwise satisfy has_permission below while the pricing engine still ran
|
||||
transaction_doctype = args.get("doctype")
|
||||
if transaction_doctype not in PRICING_TRANSACTION_DOCTYPES:
|
||||
frappe.throw(_("Invalid doctype"), frappe.PermissionError)
|
||||
|
||||
transaction_name = args.get("name")
|
||||
if not isinstance(transaction_name, str) or not frappe.db.exists(transaction_doctype, transaction_name):
|
||||
transaction_name = None
|
||||
|
||||
frappe.has_permission(transaction_doctype, doc=transaction_name, throw=True)
|
||||
|
||||
# scope by the caller's own Company restrictions, not a Company read: several roles that fill these forms hold none
|
||||
company = args.get("company")
|
||||
if company:
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, transaction_doctype)
|
||||
if allowed_companies and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
set_transaction_type(args)
|
||||
|
||||
# list of dictionaries
|
||||
@@ -772,18 +725,14 @@ def set_transaction_type(pricing_ctx: frappe._dict) -> None:
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_item_uoms(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
if filters.get("apply_on") == "Item Code":
|
||||
item_filters = [["name", "=", filters.get("value")]]
|
||||
else:
|
||||
item_filters = [[frappe.scrub(filters.get("apply_on")), "=", filters.get("value")]]
|
||||
|
||||
items = frappe.get_list("Item", filters=item_filters, pluck="name")
|
||||
if not items:
|
||||
return []
|
||||
items = [filters.get("value")]
|
||||
if filters.get("apply_on") != "Item Code":
|
||||
field = frappe.scrub(filters.get("apply_on"))
|
||||
items = [d.name for d in frappe.db.get_all("Item", filters={field: filters.get("value")})]
|
||||
|
||||
return frappe.get_all(
|
||||
"UOM Conversion Detail",
|
||||
filters={"parent": ("in", items), "parenttype": "Item", "uom": ("like", f"{txt}%")},
|
||||
filters={"parent": ("in", items), "uom": ("like", f"{txt}%")},
|
||||
fields=["uom"],
|
||||
as_list=1,
|
||||
distinct=True,
|
||||
|
||||
@@ -133,17 +133,14 @@ def initialize_parallel_threads(docname: str):
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
# checked before the status is read, not inside the branch: otherwise an unentitled caller
|
||||
# learns the document's status from whether this returns or throws
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
initialize_parallel_threads(docname)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def pause_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
@@ -160,7 +157,7 @@ def pause_pcv_processing(docname: str):
|
||||
qb.update(ppcvd).set(ppcvd.status, "Paused").where(ppcvd.name.isin(queued_dates)).run()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def cancel_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
|
||||
|
||||
@@ -176,7 +173,7 @@ def cancel_pcv_processing(docname: str):
|
||||
qb.update(ppcvd).set(ppcvd.status, "Cancelled").where(ppcvd.name.isin(queued_dates)).run()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def resume_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
@@ -261,11 +258,8 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
return gl_entry
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
# marks a row Running and enqueues a long job, so it needs the same write check as the sibling controls
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
|
||||
@@ -320,13 +320,6 @@ def get_html(doc, filters, entry, col, res, ageing):
|
||||
from frappe.www.printview import get_letter_head
|
||||
|
||||
letter_head = get_letter_head(doc, 0)
|
||||
# render letter head content as a template so its Jinja resolves against the doc
|
||||
if letter_head.get("content"):
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
|
||||
letter_head["content"] = frappe.render_template(letter_head["content"], {"doc": doc})
|
||||
if letter_head.get("footer"):
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
|
||||
letter_head["footer"] = frappe.render_template(letter_head["footer"], {"doc": doc})
|
||||
html = frappe.render_template(
|
||||
template_path,
|
||||
{
|
||||
|
||||
@@ -514,11 +514,6 @@ def validate_docs_for_voucher_types(doc_voucher_types):
|
||||
def get_repost_allowed_types(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict
|
||||
):
|
||||
# Reached only from this doctype's own form (repost_accounting_ledger.js:8), and Repost
|
||||
# Accounting Ledger is System-Manager-only, so the form is the boundary. `filters` is passed
|
||||
# straight to db.get_all, which is why reaching it needs to be gated rather than merely typed.
|
||||
frappe.has_permission("Repost Accounting Ledger", throw=True)
|
||||
|
||||
if txt:
|
||||
filters.update({"document_type": ("like", f"%{txt}%")})
|
||||
|
||||
|
||||
@@ -376,26 +376,9 @@ def _apply_sales_party_details(target_doc, source_doc, details):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
# The only two targets this resolves a reference field for. Stating them rejects a caller
|
||||
# supplied doctype that would otherwise be filtered on a column it does not have.
|
||||
reference_fields = {
|
||||
"Purchase Invoice": ("inter_company_invoice_reference", "Sales Invoice"),
|
||||
"Purchase Order": ("inter_company_order_reference", "Sales Order"),
|
||||
}
|
||||
if doctype not in reference_fields:
|
||||
frappe.throw(_("Invalid doctype {0}").format(doctype), frappe.PermissionError)
|
||||
|
||||
reference_field, source_doctype = reference_fields[doctype]
|
||||
|
||||
# `reference_name` is the caller's own document. The targets belong to the counterpart company
|
||||
# and the caller legitimately may not be able to read them, so the source is what decides
|
||||
# access here rather than the doctype being counted. doc= brings User Permissions in.
|
||||
frappe.has_permission(source_doctype, doc=reference_name, throw=True)
|
||||
|
||||
# `reference_fieldname` is selected as a column below and its value becomes the result key,
|
||||
# so an unchecked one returns any field of the item table to the caller.
|
||||
if not frappe.get_meta(doctype + " Item").has_field(reference_fieldname):
|
||||
frappe.throw(_("Invalid field {0}").format(reference_fieldname), frappe.PermissionError)
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
if doctype == "Purchase Order":
|
||||
reference_field = "inter_company_order_reference"
|
||||
|
||||
filters = {
|
||||
reference_field: reference_name,
|
||||
|
||||
@@ -52,7 +52,7 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
from erpnext.stock.get_item_details import get_item_tax_map
|
||||
from erpnext.stock.utils import _get_incoming_rate, get_stock_balance
|
||||
from erpnext.stock.utils import get_incoming_rate, get_stock_balance
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -3356,7 +3356,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
rate = 0.0
|
||||
for d in si.get("items"):
|
||||
rate = _get_incoming_rate(
|
||||
rate = get_incoming_rate(
|
||||
{
|
||||
"item_code": d.item_code,
|
||||
"warehouse": d.warehouse,
|
||||
|
||||
@@ -9,15 +9,10 @@ frappe.ui.form.on("Shipping Rule", {
|
||||
},
|
||||
|
||||
company: function (frm) {
|
||||
if (frm.previous_company !== frm.doc.company) {
|
||||
frm.previous_company = frm.doc.company;
|
||||
frm.set_value("account", "");
|
||||
}
|
||||
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frm.previous_company = frm.doc.company;
|
||||
frm.set_query("account", function () {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -52,23 +52,10 @@ class ShippingRule(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_account_company()
|
||||
self.validate_from_to_values()
|
||||
self.sort_shipping_rule_conditions()
|
||||
self.validate_overlapping_shipping_rule_conditions()
|
||||
|
||||
def validate_account_company(self):
|
||||
if not self.company or not self.account:
|
||||
return
|
||||
|
||||
if frappe.get_cached_value("Account", self.account, "company") != self.company:
|
||||
throw(
|
||||
_("Shipping Account {0} does not belong to Company {1}").format(
|
||||
frappe.bold(self.account), frappe.bold(self.company)
|
||||
),
|
||||
title=_("Invalid Shipping Account"),
|
||||
)
|
||||
|
||||
def validate_from_to_values(self):
|
||||
if self.calculate_based_on == "Fixed":
|
||||
if self.conditions:
|
||||
|
||||
@@ -15,37 +15,6 @@ class TestShippingRule(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.load_test_records("Shipping Rule")
|
||||
|
||||
def test_account_company_on_insert(self):
|
||||
for rule_type in ("Selling", "Buying"):
|
||||
with self.subTest(shipping_rule_type=rule_type):
|
||||
shipping_rule = frappe.copy_doc(self.globalTestRecords["Shipping Rule"][0])
|
||||
shipping_rule.label = f"{rule_type} Delivery"
|
||||
shipping_rule.shipping_rule_type = rule_type
|
||||
shipping_rule.company = "_Test Company 1"
|
||||
shipping_rule.cost_center = None
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "does not belong to Company"):
|
||||
shipping_rule.insert()
|
||||
|
||||
def test_account_company_on_update(self):
|
||||
shipping_rule = create_shipping_rule("Selling", "Standard Delivery")
|
||||
shipping_rule.company = "_Test Company 1"
|
||||
shipping_rule.cost_center = None
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "does not belong to Company"):
|
||||
shipping_rule.save()
|
||||
|
||||
shipping_rule.reload()
|
||||
shipping_rule.company = "_Test Company 1"
|
||||
shipping_rule.account = "_Test Account Shipping Charges - _TC1"
|
||||
shipping_rule.cost_center = None
|
||||
shipping_rule.save()
|
||||
shipping_rule.reload()
|
||||
self.assertEqual(shipping_rule.company, "_Test Company 1")
|
||||
self.assertEqual(shipping_rule.account, "_Test Account Shipping Charges - _TC1")
|
||||
|
||||
shipping_rule.account = "_Test Account Shipping Charges - _TC"
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "does not belong to Company"):
|
||||
shipping_rule.save()
|
||||
|
||||
def test_from_greater_than_to(self):
|
||||
shipping_rule = frappe.copy_doc(self.globalTestRecords["Shipping Rule"][0])
|
||||
shipping_rule.name = self.globalTestRecords["Shipping Rule"][0].get("name")
|
||||
|
||||
@@ -145,12 +145,6 @@ def get_party_details(party: str | None, party_type: str, args: dict | None = No
|
||||
out = {}
|
||||
billing_address, shipping_address = None, None
|
||||
if args:
|
||||
# each of these names a single Address. A dict is read as a filter instead, and `get_doc`
|
||||
# would resolve it to whichever Address happens to match, so only a plain name is accepted
|
||||
for fieldname in ("billing_address", "shipping_address"):
|
||||
if args.get(fieldname) and not isinstance(args.get(fieldname), str):
|
||||
frappe.throw(_("Invalid address"), frappe.PermissionError)
|
||||
|
||||
if args.get("billing_address"):
|
||||
billing_address = frappe.get_doc("Address", args.get("billing_address"))
|
||||
if args.get("shipping_address"):
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -156,7 +156,7 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "for_revaluation_journals",
|
||||
label: __("Include Revaluation Journals"),
|
||||
label: __("Revaluation Journals"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -113,7 +113,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "for_revaluation_journals",
|
||||
label: __("Include Revaluation Journals"),
|
||||
label: __("Revaluation Journals"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -183,7 +183,7 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "for_revaluation_journals",
|
||||
label: __("Include Revaluation Journals"),
|
||||
label: __("Revaluation Journals"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -141,7 +141,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "for_revaluation_journals",
|
||||
label: __("Include Revaluation Journals"),
|
||||
label: __("Revaluation Journals"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import get_cost_centers_with_children
|
||||
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
|
||||
from erpnext.stock.utils import _get_incoming_rate
|
||||
from erpnext.stock.utils import get_incoming_rate
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -969,7 +969,7 @@ class GrossProfitGenerator:
|
||||
if row.serial_and_batch_bundle:
|
||||
args.update({"serial_and_batch_bundle": row.serial_and_batch_bundle})
|
||||
|
||||
average_buying_rate = _get_incoming_rate(args)
|
||||
average_buying_rate = get_incoming_rate(args)
|
||||
self.average_buying_rate[key] = flt(average_buying_rate)
|
||||
|
||||
return self.average_buying_rate[key]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, Min, Sum
|
||||
from frappe.query_builder.functions import Coalesce, Max, Min, Sum
|
||||
from frappe.utils import cstr
|
||||
|
||||
|
||||
@@ -47,23 +47,19 @@ def get_columns(filters):
|
||||
|
||||
def get_pos_sales_payment_data(filters):
|
||||
sales_invoice_data = get_pos_invoice_data(filters)
|
||||
labels = get_pos_row_labels(filters)
|
||||
|
||||
data = []
|
||||
for row in sales_invoice_data:
|
||||
label = labels.get(get_pos_row_key(row)) or frappe._dict()
|
||||
data.append(
|
||||
[
|
||||
row["posting_date"],
|
||||
row["owner"],
|
||||
label.mode_of_payment,
|
||||
row["net_total"],
|
||||
row["total_taxes"],
|
||||
row["paid_amount"],
|
||||
row["warehouse"],
|
||||
label.cost_center,
|
||||
]
|
||||
)
|
||||
data = [
|
||||
[
|
||||
row["posting_date"],
|
||||
row["owner"],
|
||||
row["mode_of_payment"],
|
||||
row["net_total"],
|
||||
row["total_taxes"],
|
||||
row["paid_amount"],
|
||||
row["warehouse"],
|
||||
row["cost_center"],
|
||||
]
|
||||
for row in sales_invoice_data
|
||||
]
|
||||
|
||||
return data
|
||||
|
||||
@@ -127,17 +123,25 @@ def apply_conditions(query, a, filters):
|
||||
return query
|
||||
|
||||
|
||||
def get_invoice_item_totals():
|
||||
"""One row per invoice: summed item base_total, plus warehouse and cost_center off its first line."""
|
||||
def get_pos_invoice_data(filters):
|
||||
sii = frappe.qb.DocType("Sales Invoice Item")
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
|
||||
# t1: one row per invoice with the summed item base_total. warehouse and cost_center describe an
|
||||
# item line, not the invoice, and an invoice may carry several. warehouse then becomes an outer
|
||||
# grouping key below, so which line wins decides how rows are partitioned and what each row totals
|
||||
# -- not merely which label is shown. Max() over text is a sort, and MariaDB (case-folding) and
|
||||
# PostgreSQL (byte order) resolve it differently, so take both off one real line instead.
|
||||
# The representative is the first line the user entered: Min(idx) is an integer, so the pick is
|
||||
# free of collation and is meaningful, rather than turning on an unrelated hash-named row.
|
||||
grouped_items = (
|
||||
frappe.qb.from_(sii)
|
||||
.select(sii.parent, Sum(sii.amount).as_("base_total"), Min(sii.idx).as_("representative_idx"))
|
||||
.groupby(sii.parent)
|
||||
).as_("grouped_items")
|
||||
representative_item = frappe.qb.DocType("Sales Invoice Item").as_("representative_item")
|
||||
|
||||
return (
|
||||
t1 = (
|
||||
frappe.qb.from_(grouped_items)
|
||||
.inner_join(representative_item)
|
||||
.on(
|
||||
@@ -152,45 +156,12 @@ def get_invoice_item_totals():
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_invoice_totals():
|
||||
"""Invoice-level aggregates, grouped by the primary key so every plain column is dependent."""
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
|
||||
return (
|
||||
frappe.qb.from_(si)
|
||||
.select(
|
||||
si.docstatus,
|
||||
si.company,
|
||||
si.customer,
|
||||
si.is_pos,
|
||||
si.name,
|
||||
si.posting_date,
|
||||
si.owner,
|
||||
si.creation,
|
||||
Sum(si.base_total).as_("base_total"),
|
||||
Sum(si.net_total).as_("net_total"),
|
||||
Sum(si.total_taxes_and_charges).as_("total_taxes"),
|
||||
Sum(si.base_paid_amount).as_("paid_amount"),
|
||||
Sum(si.outstanding_amount).as_("outstanding_amount"),
|
||||
)
|
||||
.groupby(si.name)
|
||||
)
|
||||
|
||||
|
||||
def get_pos_row_key(row):
|
||||
return (row.owner, row.posting_date, row.warehouse)
|
||||
|
||||
|
||||
def get_representative_payments():
|
||||
"""One payment line per invoice: the first the user entered."""
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
# t3: mode_of_payment per invoice, from one real payment line for the same reason
|
||||
grouped_payments = (
|
||||
frappe.qb.from_(sip).select(sip.parent, Min(sip.idx).as_("representative_idx")).groupby(sip.parent)
|
||||
).as_("grouped_payments")
|
||||
representative_payment = frappe.qb.DocType("Sales Invoice Payment").as_("representative_payment")
|
||||
|
||||
return (
|
||||
t3 = (
|
||||
frappe.qb.from_(grouped_payments)
|
||||
.inner_join(representative_payment)
|
||||
.on(
|
||||
@@ -200,15 +171,26 @@ def get_representative_payments():
|
||||
.select(grouped_payments.parent, representative_payment.mode_of_payment.as_("mode_of_payment"))
|
||||
)
|
||||
|
||||
|
||||
def get_pos_row_labels(filters):
|
||||
"""cost_center and mode_of_payment off the earliest invoice in each row.
|
||||
|
||||
Ordered in Python rather than SQL, so no database collation applies to the tie-break.
|
||||
"""
|
||||
t1 = get_invoice_item_totals()
|
||||
t3 = get_representative_payments()
|
||||
a = get_invoice_totals()
|
||||
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns
|
||||
# (incl. customer, needed by the customer filter) are functionally dependent and valid on Postgres.
|
||||
a = (
|
||||
frappe.qb.from_(si)
|
||||
.select(
|
||||
si.docstatus,
|
||||
si.company,
|
||||
si.customer,
|
||||
si.is_pos,
|
||||
si.name,
|
||||
si.posting_date,
|
||||
si.owner,
|
||||
Sum(si.base_total).as_("base_total"),
|
||||
Sum(si.net_total).as_("net_total"),
|
||||
Sum(si.total_taxes_and_charges).as_("total_taxes"),
|
||||
Sum(si.base_paid_amount).as_("paid_amount"),
|
||||
Sum(si.outstanding_amount).as_("outstanding_amount"),
|
||||
)
|
||||
.groupby(si.name)
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(t1)
|
||||
@@ -216,37 +198,6 @@ def get_pos_row_labels(filters):
|
||||
.on(t3.parent == t1.parent)
|
||||
.join(a)
|
||||
.on((t1.parent == a.name) & (t1.base_total == a.base_total))
|
||||
.select(
|
||||
a.owner,
|
||||
a.posting_date,
|
||||
a.creation,
|
||||
a.name,
|
||||
t1.warehouse,
|
||||
t1.cost_center,
|
||||
t3.mode_of_payment,
|
||||
)
|
||||
.where(a.docstatus == 1)
|
||||
)
|
||||
query = apply_conditions(query, a, filters)
|
||||
|
||||
labels = {}
|
||||
for row in query.run(as_dict=True):
|
||||
key = get_pos_row_key(row)
|
||||
current = labels.get(key)
|
||||
if current is None or (row.creation, row.name) < (current.creation, current.name):
|
||||
labels[key] = row
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def get_pos_invoice_data(filters):
|
||||
t1 = get_invoice_item_totals()
|
||||
a = get_invoice_totals()
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(t1)
|
||||
.join(a)
|
||||
.on((t1.parent == a.name) & (t1.base_total == a.base_total))
|
||||
.select(
|
||||
a.posting_date,
|
||||
a.owner,
|
||||
@@ -254,7 +205,10 @@ def get_pos_invoice_data(filters):
|
||||
Sum(a.total_taxes).as_("total_taxes"),
|
||||
Sum(a.paid_amount).as_("paid_amount"),
|
||||
Sum(a.outstanding_amount).as_("outstanding_amount"),
|
||||
# mode_of_payment/cost_center are not in the outer GROUP BY -> Max() (deterministic, both engines)
|
||||
Max(t3.mode_of_payment).as_("mode_of_payment"),
|
||||
t1.warehouse,
|
||||
Max(t1.cost_center).as_("cost_center"),
|
||||
)
|
||||
.where(a.docstatus == 1)
|
||||
.groupby(a.owner, a.posting_date, t1.warehouse)
|
||||
|
||||
@@ -9,8 +9,6 @@ from erpnext.accounts.report.sales_payment_summary.sales_payment_summary import
|
||||
get_mode_of_payment_details,
|
||||
get_mode_of_payments,
|
||||
get_pos_invoice_data,
|
||||
get_pos_row_key,
|
||||
get_pos_row_labels,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -96,43 +94,12 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
|
||||
posted = {(row.warehouse, row.cost_center) for row in si.items}
|
||||
self.assertGreater(len(posted), 1, "fixture must post more than one distinct pair")
|
||||
|
||||
labels = get_pos_row_labels(get_filters())
|
||||
rows = get_pos_invoice_data(get_filters())
|
||||
reported = [r for r in rows if r.get("warehouse") in {w for w, _ in posted}]
|
||||
self.assertTrue(reported)
|
||||
|
||||
for row in reported:
|
||||
label = labels[get_pos_row_key(row)]
|
||||
self.assertIn((row["warehouse"], label.cost_center), posted)
|
||||
|
||||
def test_pos_row_labels_come_from_the_earliest_invoice(self):
|
||||
"""The reported cost centre and payment mode must be one invoice's, and the same one's.
|
||||
|
||||
A row covers every invoice sharing an owner, date and warehouse, so neither column describes
|
||||
it. Aggregating each independently sorts text -- which the two engines resolve differently --
|
||||
and can pair one invoice's cost centre with another's payment mode.
|
||||
"""
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
warehouse = create_warehouse("_Test POS Row Labels")
|
||||
card = create_mode_of_payment("_Test POS Card", "_Test Bank - _TC")
|
||||
|
||||
# cross the two picks: the earlier invoice holds the lower cost centre and the higher mode
|
||||
posted = [("Main - _TC", card, "_Test Bank - _TC"), ("Sub - _TC", "Cash", "_Test Cash - _TC")]
|
||||
for cost_center, mode_of_payment, account in posted:
|
||||
si = create_sales_invoice_record()
|
||||
si.is_pos = 1
|
||||
si.items[0].warehouse = warehouse
|
||||
si.items[0].cost_center = cost_center
|
||||
si.append("payments", {"mode_of_payment": mode_of_payment, "account": account, "amount": 10000})
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
rows = [row for row in get_pos_invoice_data(get_filters()) if row.get("warehouse") == warehouse]
|
||||
self.assertEqual(len(rows), 1, "the reported row count must not change")
|
||||
|
||||
label = get_pos_row_labels(get_filters())[get_pos_row_key(rows[0])]
|
||||
self.assertEqual((label.cost_center, label.mode_of_payment), ("Main - _TC", card))
|
||||
self.assertIn((row["warehouse"], row["cost_center"]), posted)
|
||||
|
||||
def test_get_mode_of_payments_details(self):
|
||||
filters = get_filters()
|
||||
@@ -215,21 +182,6 @@ def get_filters():
|
||||
return {"from_date": "1900-01-01", "to_date": today(), "company": "_Test Company"}
|
||||
|
||||
|
||||
def create_mode_of_payment(name, account, company="_Test Company"):
|
||||
"""A POS payment row needs its mode to carry a default account for the company."""
|
||||
if not frappe.db.exists("Mode of Payment", name):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Mode of Payment",
|
||||
"mode_of_payment": name,
|
||||
"type": "Bank",
|
||||
"accounts": [{"company": company, "default_account": account}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def create_sales_invoice_record(qty=1):
|
||||
# return sales invoice doc object
|
||||
return frappe.get_doc(
|
||||
|
||||
@@ -181,27 +181,6 @@ class TaxService:
|
||||
return amount, base_amount
|
||||
|
||||
|
||||
# the only doctypes a `taxes_and_charges` Link points at; `master_doctype` is caller-supplied and reaches get_doc()
|
||||
TAX_MASTER_DOCTYPES = ("Sales Taxes and Charges Template", "Purchase Taxes and Charges Template")
|
||||
|
||||
|
||||
def validate_tax_master(master_doctype: str, master_name: str | None = None) -> None:
|
||||
if master_doctype not in TAX_MASTER_DOCTYPES:
|
||||
frappe.throw(_("Invalid tax master doctype"), frappe.PermissionError)
|
||||
|
||||
if not master_name:
|
||||
return
|
||||
|
||||
# keep a company-restricted caller inside their own companies; this does NOT authorise the template itself
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, master_doctype)
|
||||
if allowed_companies:
|
||||
company = frappe.db.get_value(master_doctype, master_name, "company")
|
||||
if company and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_tax_rate(account_head: str) -> dict:
|
||||
return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True)
|
||||
@@ -214,8 +193,6 @@ def get_default_taxes_and_charges(
|
||||
if not company:
|
||||
return {}
|
||||
|
||||
validate_tax_master(master_doctype, tax_template)
|
||||
|
||||
if tax_template and company:
|
||||
tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company")
|
||||
if tax_template_company == company:
|
||||
@@ -233,9 +210,6 @@ def get_default_taxes_and_charges(
|
||||
def get_taxes_and_charges(master_doctype: str, master_name: str | None = None) -> list | None:
|
||||
if not master_name:
|
||||
return
|
||||
|
||||
validate_tax_master(master_doctype, master_name)
|
||||
|
||||
from frappe.model import child_table_fields, default_fields
|
||||
|
||||
tax_master = frappe.get_doc(master_doctype, master_name)
|
||||
|
||||
@@ -2415,8 +2415,8 @@ class QueryPaymentLedger:
|
||||
.groupby(ple.account, ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
|
||||
).as_("grouped")
|
||||
|
||||
# KNOWN DIVERGENCE: Min(name) is a text sort. Hash names are not reliably lower case -- the
|
||||
# trace-id prefix is not lowered -- so the engines can pick different rows here.
|
||||
# Payment Ledger Entry has no autoname rule, so frappe names it by hash -- lower-case, which
|
||||
# keeps Min(name) free of the collation divergence that picking Max() over free text has.
|
||||
representative_ple = qb.DocType("Payment Ledger Entry").as_("representative_ple")
|
||||
query_voucher_amount = (
|
||||
qb.from_(grouped_voucher_amount)
|
||||
|
||||
@@ -546,22 +546,15 @@ frappe.ui.form.on("Asset", {
|
||||
},
|
||||
|
||||
set_finance_book: function (frm) {
|
||||
let item_code = frm.doc.item_code;
|
||||
let net_purchase_amount = frm.doc.net_purchase_amount;
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.assets.doctype.asset.asset.get_item_details",
|
||||
args: {
|
||||
item_code: item_code,
|
||||
item_code: frm.doc.item_code,
|
||||
asset_category: frm.doc.asset_category,
|
||||
net_purchase_amount: net_purchase_amount,
|
||||
net_purchase_amount: frm.doc.net_purchase_amount,
|
||||
},
|
||||
callback: function (r, rt) {
|
||||
if (
|
||||
r.message &&
|
||||
frm.doc.item_code === item_code &&
|
||||
frm.doc.net_purchase_amount === net_purchase_amount
|
||||
) {
|
||||
if (r.message) {
|
||||
frm.set_value("finance_books", r.message);
|
||||
}
|
||||
},
|
||||
@@ -759,12 +752,10 @@ frappe.ui.form.on("Asset", {
|
||||
},
|
||||
|
||||
net_purchase_amount: function (frm) {
|
||||
if (frm.doc.finance_books && frm.doc.finance_books.length) {
|
||||
if (frm.doc.finance_books) {
|
||||
frm.doc.finance_books.forEach((d) => {
|
||||
frm.events.set_depreciation_rate(frm, d);
|
||||
});
|
||||
} else if (frm.doc.item_code && frm.doc.calculate_depreciation && frm.doc.net_purchase_amount) {
|
||||
frm.trigger("set_finance_book");
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1171,36 +1171,15 @@ def get_asset_value_after_depreciation(
|
||||
asset_name: str,
|
||||
finance_book: str | None = None,
|
||||
):
|
||||
# one of the three calling forms is the boundary; Asset itself excludes the roles holding Asset Value Adjustment write
|
||||
if not any(
|
||||
frappe.has_permission(dt, "write")
|
||||
for dt in ("Asset Value Adjustment", "Asset Capitalization", "Asset Repair")
|
||||
):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
|
||||
_check_asset_company(asset.company)
|
||||
|
||||
if not asset.calculate_depreciation:
|
||||
return flt(asset.value_after_depreciation)
|
||||
|
||||
return asset.get_value_after_depreciation(finance_book)
|
||||
|
||||
|
||||
def _check_asset_company(company: str | None) -> None:
|
||||
"""Keep a company-restricted caller inside their own companies; a no-op for everyone else."""
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Asset")
|
||||
if allowed_companies and company and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def has_active_capitalization(asset: str):
|
||||
frappe.has_permission("Asset", doc=asset, throw=True)
|
||||
|
||||
active_capitalizations = frappe.db.count(
|
||||
"Asset Capitalization", filters={"target_asset": asset, "docstatus": 1}
|
||||
)
|
||||
@@ -1213,21 +1192,7 @@ def get_values_from_purchase_doc(
|
||||
item_code: str,
|
||||
doctype: str,
|
||||
):
|
||||
# `doctype` is caller-supplied and reaches frappe.get_doc() as the doctype itself, so without
|
||||
# this list any document with an `items` table could be read for its valuation rates. The two
|
||||
# values below are the only ones this function handles — see the branches further down.
|
||||
if doctype not in ("Purchase Receipt", "Purchase Invoice"):
|
||||
frappe.throw(_("Invalid document type"), frappe.PermissionError)
|
||||
|
||||
# The caller is filling in an Asset (asset.js:794), and the Asset form is the boundary: Quality
|
||||
# Manager writes Assets but holds read on neither Purchase Receipt nor Purchase Invoice, so the
|
||||
# purchase document cannot be it.
|
||||
frappe.has_permission("Asset", "write", throw=True)
|
||||
|
||||
purchase_doc = frappe.get_doc(doctype, purchase_doc_name)
|
||||
|
||||
_check_asset_company(purchase_doc.company)
|
||||
|
||||
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]
|
||||
|
||||
if not matching_items:
|
||||
|
||||
@@ -29,7 +29,7 @@ from erpnext.stock.get_item_details import (
|
||||
get_item_warehouse_,
|
||||
)
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
from erpnext.stock.utils import _get_incoming_rate, check_warehouse_company
|
||||
from erpnext.stock.utils import get_incoming_rate
|
||||
|
||||
force_fields = [
|
||||
"target_item_name",
|
||||
@@ -191,7 +191,7 @@ class AssetCapitalization(StockController):
|
||||
cumulative_qty += flt(d.stock_qty)
|
||||
args = self.get_args_for_incoming_rate(d)
|
||||
args["qty"] = -1 * cumulative_qty
|
||||
cumulative_rate = flt(_get_incoming_rate(args, raise_error_if_no_rate=False))
|
||||
cumulative_rate = flt(get_incoming_rate(args, raise_error_if_no_rate=False))
|
||||
cumulative_value = cumulative_rate * cumulative_qty
|
||||
|
||||
row_value = cumulative_value - prev_cumulative_value
|
||||
@@ -326,8 +326,6 @@ class AssetCapitalization(StockController):
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_warehouse_details(self):
|
||||
self.check_permission("write")
|
||||
|
||||
for d in self.get("stock_items"):
|
||||
if d.item_code and d.warehouse:
|
||||
args = self.get_args_for_incoming_rate(d)
|
||||
@@ -338,8 +336,6 @@ class AssetCapitalization(StockController):
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_asset_values(self):
|
||||
self.check_permission("write")
|
||||
|
||||
for d in self.get("asset_items"):
|
||||
if d.asset:
|
||||
finance_book = d.get("finance_book") or self.get("finance_book")
|
||||
@@ -515,24 +511,8 @@ class AssetCapitalization(StockController):
|
||||
)
|
||||
|
||||
|
||||
def check_capitalization_access(company: str | None = None) -> None:
|
||||
"""Every lookup in this file feeds the Asset Capitalization form, so that form is the boundary."""
|
||||
frappe.has_permission("Asset Capitalization", throw=True)
|
||||
|
||||
if not isinstance(company, str) or not company:
|
||||
return
|
||||
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Asset Capitalization")
|
||||
if allowed_companies and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_target_item_details(item_code: str | None = None, company: str | None = None):
|
||||
check_capitalization_access(company)
|
||||
|
||||
out = frappe._dict()
|
||||
|
||||
# Get Item Details
|
||||
@@ -559,8 +539,6 @@ def get_target_item_details(item_code: str | None = None, company: str | None =
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_target_asset_details(asset: str | None = None, company: str | None = None):
|
||||
check_capitalization_access(company)
|
||||
|
||||
out = frappe._dict()
|
||||
|
||||
# Get Asset Details
|
||||
@@ -646,12 +624,10 @@ def get_warehouse_details(ctx: ItemDetailsCtx) -> frappe._dict:
|
||||
frappe.has_permission("Item", doc=ctx.item_code, throw=True)
|
||||
frappe.has_permission("Warehouse", doc=ctx.warehouse, throw=True)
|
||||
frappe.has_permission("Stock Ledger Entry", throw=True)
|
||||
# inherited from get_incoming_rate before the split; _get_incoming_rate does not scope
|
||||
check_warehouse_company(ctx.warehouse)
|
||||
out = frappe._dict(
|
||||
{
|
||||
"actual_qty": get_previous_sle(ctx).get("qty_after_transaction") or 0,
|
||||
"valuation_rate": _get_incoming_rate(ctx, raise_error_if_no_rate=False),
|
||||
"valuation_rate": get_incoming_rate(ctx, raise_error_if_no_rate=False),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -660,8 +636,6 @@ def get_warehouse_details(ctx: ItemDetailsCtx) -> frappe._dict:
|
||||
@frappe.whitelist()
|
||||
@erpnext.normalize_ctx_input(ItemDetailsCtx)
|
||||
def get_consumed_asset_details(ctx: ItemDetailsCtx) -> frappe._dict:
|
||||
check_capitalization_access(ctx.get("company"))
|
||||
|
||||
out = frappe._dict()
|
||||
|
||||
asset_details = frappe._dict()
|
||||
@@ -708,8 +682,6 @@ def get_consumed_asset_details(ctx: ItemDetailsCtx) -> frappe._dict:
|
||||
@frappe.whitelist()
|
||||
@erpnext.normalize_ctx_input(ItemDetailsCtx)
|
||||
def get_service_item_details(ctx: ItemDetailsCtx) -> frappe._dict:
|
||||
check_capitalization_access(ctx.get("company"))
|
||||
|
||||
out = frappe._dict()
|
||||
|
||||
item = frappe._dict()
|
||||
@@ -734,8 +706,6 @@ def get_service_item_details(ctx: ItemDetailsCtx) -> frappe._dict:
|
||||
def get_items_tagged_to_wip_composite_asset(params: dict | str):
|
||||
params = frappe.parse_json(params)
|
||||
|
||||
check_capitalization_access(params.get("company") if isinstance(params, dict | frappe._dict) else None)
|
||||
|
||||
fields = [
|
||||
"item_code",
|
||||
"item_name",
|
||||
|
||||
@@ -351,20 +351,6 @@ class AssetRepair(AccountsController):
|
||||
add_asset_activity(self.asset, subject)
|
||||
|
||||
|
||||
def check_asset_repair_access(company: str | None = None) -> None:
|
||||
"""Both pickers below sit on the Asset Repair form, so that form is the boundary, not Purchase Invoice."""
|
||||
frappe.has_permission("Asset Repair", throw=True)
|
||||
|
||||
if not isinstance(company, str) or not company:
|
||||
return
|
||||
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Asset Repair")
|
||||
if allowed_companies and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_downtime(failure_date: DateTimeLikeObject, completion_date: DateTimeLikeObject):
|
||||
downtime = time_diff_in_hours(completion_date, failure_date)
|
||||
@@ -385,8 +371,6 @@ def get_purchase_invoice(
|
||||
Get Purchase Invoices that have expense accounts for non-stock items.
|
||||
Only returns invoices with at least one non-stock, non-fixed-asset item with an expense account.
|
||||
"""
|
||||
check_asset_repair_access(filters.get("company") if isinstance(filters, dict) else None)
|
||||
|
||||
pi = DocType("Purchase Invoice")
|
||||
pi_item = DocType("Purchase Invoice Item")
|
||||
item = DocType("Item")
|
||||
@@ -429,8 +413,6 @@ def get_expense_accounts(
|
||||
Get expense accounts for non-stock (service) items from the purchase invoice.
|
||||
Used as a query function for link fields.
|
||||
"""
|
||||
check_asset_repair_access()
|
||||
|
||||
purchase_invoice = filters.get("purchase_invoice")
|
||||
if not purchase_invoice:
|
||||
return []
|
||||
|
||||
@@ -611,19 +611,14 @@ def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor=
|
||||
return item_last_purchase_rate
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def close_or_unclose_purchase_orders(names: str | list, status: str):
|
||||
frappe.has_permission("Purchase Order", "write", throw=True)
|
||||
if not frappe.has_permission("Purchase Order", "write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
names = frappe.parse_json(names)
|
||||
for name in names:
|
||||
if not isinstance(name, str):
|
||||
frappe.throw(_("Invalid name"), frappe.PermissionError)
|
||||
|
||||
# the check above is doctype level and never consults User Permissions, so on its own it
|
||||
# lets a caller restricted to one company close another company's orders. Checking each
|
||||
# document is what scopes it, and matches what update_status() below already does.
|
||||
po = frappe.get_lazy_doc("Purchase Order", name, check_permission="submit")
|
||||
po = frappe.get_lazy_doc("Purchase Order", name)
|
||||
if po.docstatus == 1:
|
||||
if status == "Closed":
|
||||
if po.status not in ("Cancelled", "Closed") and (
|
||||
@@ -654,7 +649,7 @@ def get_list_context(context=None):
|
||||
return list_context
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def update_status(status: str, name: str):
|
||||
po = frappe.get_lazy_doc("Purchase Order", name, check_permission="submit")
|
||||
po.update_status(status)
|
||||
|
||||
@@ -8,6 +8,7 @@ from frappe.contacts.doctype.contact.contact import get_full_name
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Order
|
||||
from frappe.utils import get_url
|
||||
from frappe.utils.print_format import download_pdf
|
||||
from frappe.utils.user import get_user_fullname
|
||||
@@ -480,34 +481,32 @@ def get_supplier_tag():
|
||||
def get_rfq_containing_supplier(
|
||||
doctype: str | None, txt: str, searchfield: str | None, start: int, page_len: int, filters: dict
|
||||
):
|
||||
rfq_filters = [
|
||||
["docstatus", "=", 1],
|
||||
["company", "=", filters.get("company")],
|
||||
]
|
||||
rfq = frappe.qb.DocType("Request for Quotation")
|
||||
rfq_supplier = frappe.qb.DocType("Request for Quotation Supplier")
|
||||
|
||||
if frappe.has_permission("Request for Quotation", "read"):
|
||||
rfq_filters.append(["Request for Quotation Supplier", "supplier", "=", filters.get("supplier")])
|
||||
else:
|
||||
parents = frappe.get_all(
|
||||
"Request for Quotation Supplier",
|
||||
filters={"supplier": filters.get("supplier"), "parenttype": "Request for Quotation"},
|
||||
pluck="parent",
|
||||
distinct=True,
|
||||
query = (
|
||||
frappe.qb.from_(rfq)
|
||||
.from_(rfq_supplier)
|
||||
.select(rfq.name)
|
||||
.distinct()
|
||||
.select(rfq.transaction_date, rfq.company)
|
||||
.where(
|
||||
(rfq.name == rfq_supplier.parent)
|
||||
& (rfq_supplier.supplier == filters.get("supplier"))
|
||||
& (rfq.docstatus == 1)
|
||||
& (rfq.company == filters.get("company"))
|
||||
)
|
||||
rfq_filters.append(["name", "in", parents or [""]])
|
||||
.orderby(rfq.transaction_date, order=Order.asc)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
|
||||
if txt:
|
||||
rfq_filters.append(["name", "like", f"%{txt}%"])
|
||||
query = query.where(rfq.name.like(f"%%{txt}%%"))
|
||||
|
||||
if filters.get("transaction_date"):
|
||||
rfq_filters.append(["transaction_date", "=", filters.get("transaction_date")])
|
||||
query = query.where(rfq.transaction_date == filters.get("transaction_date"))
|
||||
|
||||
return frappe.get_list(
|
||||
"Request for Quotation",
|
||||
filters=rfq_filters,
|
||||
fields=["name", "transaction_date", "company"],
|
||||
group_by="name",
|
||||
order_by="transaction_date asc",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
)
|
||||
rfq_data = query.run(as_dict=1)
|
||||
|
||||
return rfq_data
|
||||
|
||||
@@ -242,16 +242,6 @@ def get_supplier_primary(
|
||||
):
|
||||
supplier = filters.get("supplier")
|
||||
type = filters.get("type")
|
||||
|
||||
# `type` is caller-supplied and was interpolated straight into qb.DocType(), so any doctype on
|
||||
# the site could be joined to Dynamic Link and read. The two pickers that call this
|
||||
# (supplier.js:51,61) send only these two values.
|
||||
if type not in ("Contact", "Address"):
|
||||
frappe.throw(_("Invalid type"), frappe.PermissionError)
|
||||
|
||||
# authorise the party, not Contact/Address: the `if_owner` row on Address would empty the picker rather than error
|
||||
frappe.has_permission("Supplier", doc=supplier, throw=True)
|
||||
|
||||
type_doctype = frappe.qb.DocType(type)
|
||||
dynamic_link = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@
|
||||
"doctype": "Print Format",
|
||||
"font": "Inter",
|
||||
"font_size": 13,
|
||||
"format_data": "{\"header\":{\"columns\":[{\"label\":\"\",\"fields\":[]}]},\"sections\":[{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Custom HTML\",\"fieldname\":\"custom_html_jtLStRVi\",\"fieldtype\":\"HTML\",\"html\":\"<div>\\n <div style=\\\"color:#6b7280;\\\">\\n Supplier\\n </div>\\n</div>\",\"custom\":1},{\"label\":\"Supplier\",\"fieldname\":\"vendor\",\"fieldtype\":\"Link\",\"show_label\":\"hide\",\"custom_style\":\"font-weight: bold;\"}],\"width\":53},{\"label\":\"\",\"fields\":[{\"label\":\"Request for Quotation\",\"fieldname\":\"name\",\"fieldtype\":\"Data\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"font-weight: bold;\\nborder-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\",\"label_color\":\"#292929\"},{\"label\":\"Order Date\",\"fieldname\":\"transaction_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"},{\"label\":\"Required By\",\"fieldname\":\"schedule_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"}],\"width\":44}],\"show_label\":\"hide\",\"field_orientation\":\"left-right\",\"margin\":{\"top\":15,\"right\":12,\"bottom\":0,\"left\":12}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"\",\"fieldname\":\"items\",\"fieldtype\":\"Table\",\"options\":\"Request for Quotation Item\",\"table_columns\":[{\"label\":\"No\",\"fieldname\":\"idx\",\"fieldtype\":\"Data\",\"width\":5},{\"label\":\"Item\",\"fieldname\":\"item_name\",\"fieldtype\":\"Data\",\"width\":21,\"merged_fields\":[{\"fieldname\":\"description\",\"fieldtype\":\"Text Editor\",\"style\":\"secondary\"}]},{\"label\":\"Code\",\"fieldname\":\"item_code\",\"fieldtype\":\"Link\",\"options\":\"Item\",\"width\":12},{\"label\":\"Quantity\",\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"options\":\"UOM\",\"width\":13,\"merged_fields\":[{\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity != 1\"},{\"label\":\"Quantity\",\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"width\":14,\"merged_fields\":[{\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity\"}],\"table_style\":\"lined\",\"table_bordered\":true,\"table_header\":\"styled\",\"table_cell_padding\":10,\"table_radius\":10,\"table_header_bg\":\"#f3f3f3\",\"show_label\":\"hide\"}]}],\"has_fields\":true,\"margin\":{\"top\":15,\"right\":0,\"bottom\":0,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_LeiIYjph\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Terms and Conditions Details\",\"fieldname\":\"terms\",\"fieldtype\":\"Text Editor\"}]}],\"margin\":{\"top\":5,\"right\":12,\"bottom\":0,\"left\":12}}],\"footer\":{\"columns\":[{\"label\":\"\",\"fields\":[]}],\"show_label\":\"hide\"}}",
|
||||
"format_data": "{\"header\":{\"columns\":[{\"label\":\"\",\"fields\":[]}]},\"sections\":[{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Custom HTML\",\"fieldname\":\"custom_html_jtLStRVi\",\"fieldtype\":\"HTML\",\"html\":\"<div>\\n <div style=\\\"color:#6b7280;\\\">\\n Supplier\\n </div>\\n</div>\",\"custom\":1},{\"label\":\"Supplier\",\"fieldname\":\"vendor\",\"fieldtype\":\"Link\",\"show_label\":\"hide\",\"custom_style\":\"font-weight: bold;\"}],\"width\":53},{\"label\":\"\",\"fields\":[{\"label\":\"Request for Quotation\",\"fieldname\":\"name\",\"fieldtype\":\"Data\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"font-weight: bold;\\nborder-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\",\"label_color\":\"#292929\"},{\"label\":\"Order Date\",\"fieldname\":\"transaction_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"},{\"label\":\"Required By\",\"fieldname\":\"schedule_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"}],\"width\":44}],\"show_label\":\"hide\",\"field_orientation\":\"left-right\",\"margin\":{\"top\":15,\"right\":12,\"bottom\":0,\"left\":12}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"\",\"fieldname\":\"items\",\"fieldtype\":\"Table\",\"options\":\"Request for Quotation Item\",\"table_columns\":[{\"label\":\"No\",\"fieldname\":\"idx\",\"fieldtype\":\"Data\",\"width\":5},{\"label\":\"Item\",\"fieldname\":\"item_name\",\"fieldtype\":\"Data\",\"width\":21},{\"label\":\"Code\",\"fieldname\":\"item_code\",\"fieldtype\":\"Link\",\"options\":\"Item\",\"width\":12},{\"label\":\"Quantity\",\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"options\":\"UOM\",\"width\":13,\"merged_fields\":[{\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity != 1\"},{\"label\":\"Quantity\",\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"width\":14,\"merged_fields\":[{\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity\"}],\"table_style\":\"lined\",\"table_bordered\":true,\"table_header\":\"styled\",\"table_cell_padding\":10,\"table_radius\":10,\"table_header_bg\":\"#f3f3f3\",\"show_label\":\"hide\"}]}],\"has_fields\":true,\"margin\":{\"top\":15,\"right\":0,\"bottom\":0,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_LeiIYjph\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Terms and Conditions Details\",\"fieldname\":\"terms\",\"fieldtype\":\"Text Editor\"}]}],\"margin\":{\"top\":5,\"right\":12,\"bottom\":0,\"left\":12}}],\"footer\":{\"columns\":[{\"label\":\"\",\"fields\":[]}],\"show_label\":\"hide\"}}",
|
||||
"idx": 0,
|
||||
"label_color": "#6b7280",
|
||||
"line_breaks": 0,
|
||||
@@ -17,7 +17,7 @@
|
||||
"margin_left": 8.0,
|
||||
"margin_right": 8.0,
|
||||
"margin_top": 10.0,
|
||||
"modified": "2026-09-16 11:31:38.723639",
|
||||
"modified": "2026-07-24 17:19:05.063875",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation Classic",
|
||||
|
||||
@@ -51,6 +51,7 @@ def get_data(filters):
|
||||
mr_item.item_code.as_("item_code"),
|
||||
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
|
||||
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
|
||||
Max(Coalesce(mr_item.stock_uom, "")).as_("stock_uom"),
|
||||
Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"),
|
||||
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
|
||||
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.received_qty, 0))).as_(
|
||||
@@ -77,14 +78,19 @@ def get_data(filters):
|
||||
|
||||
|
||||
def apply_representative_lines(rows):
|
||||
"""Fill the line-level columns from one real Material Request Item line: the first by idx."""
|
||||
"""Fill item_name/description/uom from one real Material Request Item line per group.
|
||||
|
||||
All three are editable per line, so a request listing the same item twice holds several values
|
||||
per group. Aggregating them sorts text, and MariaDB folds case while PostgreSQL orders by byte
|
||||
value, so the engines pick differently. Take the first line by idx.
|
||||
"""
|
||||
material_requests = list({row.material_request for row in rows})
|
||||
representative = {}
|
||||
if material_requests:
|
||||
for line in frappe.get_all(
|
||||
"Material Request Item",
|
||||
filters={"parent": ("in", material_requests), "docstatus": 1},
|
||||
fields=["parent", "item_code", "item_name", "description", "uom", "stock_uom"],
|
||||
fields=["parent", "item_code", "item_name", "description", "uom"],
|
||||
order_by="idx",
|
||||
):
|
||||
representative.setdefault((line.parent, line.item_code), line)
|
||||
@@ -94,7 +100,6 @@ def apply_representative_lines(rows):
|
||||
row.item_name = line.item_name if line else None
|
||||
row.description = line.description if line else None
|
||||
row.uom = line.uom if line else ""
|
||||
row.stock_uom = line.stock_uom if line else ""
|
||||
|
||||
|
||||
def get_conditions(filters, query, mr, mr_item):
|
||||
|
||||
@@ -74,54 +74,6 @@ class TestRequestedItemsToOrderAndReceive(ERPNextTestSuite):
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(getdate(data[0].required_date), getdate(add_days(today(), 1)))
|
||||
|
||||
def test_uom_pair_comes_from_one_line(self):
|
||||
"""uom and stock_uom describe a line, so the reported pair must be one that was posted.
|
||||
|
||||
A request can list the same item twice in different units. Sourcing each column separately
|
||||
can report one line's uom beside another's stock_uom -- a pair belonging to neither.
|
||||
"""
|
||||
create_item("Test MR Report Uom Item")
|
||||
mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0])
|
||||
mr.transaction_date = today()
|
||||
mr.schedule_date = add_days(today(), 5)
|
||||
mr.set("items", mr.items[:1])
|
||||
row = mr.items[0]
|
||||
row.item_code = "Test MR Report Uom Item"
|
||||
row.item_name = "Test MR Report Uom Item"
|
||||
row.description = "Test MR Report Uom Item"
|
||||
row.uom = "Nos"
|
||||
row.schedule_date = mr.schedule_date
|
||||
mr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "Test MR Report Uom Item",
|
||||
"item_name": "Test MR Report Uom Item",
|
||||
"description": "Test MR Report Uom Item",
|
||||
"uom": "Nos",
|
||||
"qty": row.qty,
|
||||
"warehouse": row.warehouse,
|
||||
"schedule_date": mr.schedule_date,
|
||||
},
|
||||
)
|
||||
mr.submit()
|
||||
|
||||
# cross the two picks: the line holding the higher uom holds the lower stock_uom, so an
|
||||
# independently aggregated pair cannot belong to either line
|
||||
for line, uom, stock_uom in ((mr.items[0], "Nos", "Box"), (mr.items[1], "Box", "Nos")):
|
||||
frappe.db.set_value(
|
||||
"Material Request Item",
|
||||
line.name,
|
||||
{"uom": uom, "stock_uom": stock_uom},
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
posted = {("Nos", "Box"), ("Box", "Nos")}
|
||||
data = get_data(self.filters.update({"item_code": "Test MR Report Uom Item"}))
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertIn((data[0].uom, data[0].stock_uom), posted)
|
||||
self.assertEqual((data[0].uom, data[0].stock_uom), ("Nos", "Box"), "must be the first line by idx")
|
||||
|
||||
def setup_material_request(self, order=False, receive=False, days=0):
|
||||
po = None
|
||||
mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0])
|
||||
|
||||
@@ -43,9 +43,7 @@ def update_last_purchase_rate(doc, is_submit) -> None:
|
||||
frappe.throw(_("UOM Conversion factor is required in row {0}").format(d.idx))
|
||||
|
||||
# update last purchsae rate
|
||||
frappe.db.set_value(
|
||||
"Item", d.item_code, "last_purchase_rate", flt(last_purchase_rate), update_modified=False
|
||||
)
|
||||
frappe.db.set_value("Item", d.item_code, "last_purchase_rate", flt(last_purchase_rate))
|
||||
|
||||
|
||||
def validate_for_items(doc) -> None:
|
||||
|
||||
@@ -1212,44 +1212,32 @@ class AccountsController(TransactionBase):
|
||||
self.unlink_ref_doc_from_po()
|
||||
|
||||
def unlink_ref_doc_from_po(self):
|
||||
so_items = [item.name for item in self.items]
|
||||
filters = {
|
||||
"sales_order": self.name,
|
||||
"sales_order_item": ["in", so_items],
|
||||
"docstatus": ["<", 2],
|
||||
}
|
||||
so_items = []
|
||||
for item in self.items:
|
||||
so_items.append(item.name)
|
||||
|
||||
linked_po_items = frappe.get_all(
|
||||
"Purchase Order Item", filters=filters, fields=["parent", "sales_order_item"]
|
||||
)
|
||||
if not linked_po_items:
|
||||
return
|
||||
|
||||
frappe.db.set_value("Purchase Order Item", filters, {"sales_order": None, "sales_order_item": None})
|
||||
self.update_ordered_qty_in_items({item.sales_order_item for item in linked_po_items})
|
||||
|
||||
linked_po = sorted({item.parent for item in linked_po_items})
|
||||
frappe.msgprint(_("Purchase Orders {0} are unlinked").format("\n".join(linked_po)))
|
||||
|
||||
def update_ordered_qty_in_items(self, so_items: set[str]):
|
||||
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
|
||||
ordered_qty = dict(
|
||||
frappe.qb.from_(purchase_order_item)
|
||||
.select(purchase_order_item.sales_order_item, Sum(purchase_order_item.stock_qty))
|
||||
.where(
|
||||
purchase_order_item.sales_order_item.isin(list(so_items))
|
||||
& (purchase_order_item.docstatus == 1)
|
||||
linked_po = list(
|
||||
set(
|
||||
frappe.get_all(
|
||||
"Purchase Order Item",
|
||||
filters={
|
||||
"sales_order": self.name,
|
||||
"sales_order_item": ["in", so_items],
|
||||
"docstatus": ["<", 2],
|
||||
},
|
||||
pluck="parent",
|
||||
)
|
||||
)
|
||||
.groupby(purchase_order_item.sales_order_item)
|
||||
.run()
|
||||
)
|
||||
|
||||
items_by_ordered_qty = defaultdict(list)
|
||||
for so_item in so_items:
|
||||
items_by_ordered_qty[flt(ordered_qty.get(so_item))].append(so_item)
|
||||
if linked_po:
|
||||
frappe.db.set_value(
|
||||
"Purchase Order Item",
|
||||
{"sales_order": self.name, "sales_order_item": ["in", so_items], "docstatus": ["<", 2]},
|
||||
{"sales_order": None, "sales_order_item": None},
|
||||
)
|
||||
|
||||
for qty, items in items_by_ordered_qty.items():
|
||||
frappe.db.set_value("Sales Order Item", {"name": ["in", items]}, "ordered_qty", qty)
|
||||
frappe.msgprint(_("Purchase Orders {0} are unlinked").format("\n".join(linked_po)))
|
||||
|
||||
def get_company_default(self, fieldname, ignore_validation=False):
|
||||
from erpnext.accounts.utils import get_company_default
|
||||
|
||||
@@ -23,7 +23,7 @@ from erpnext.stock.get_item_details import (
|
||||
get_conversion_factor,
|
||||
get_item_defaults,
|
||||
)
|
||||
from erpnext.stock.utils import _get_incoming_rate
|
||||
from erpnext.stock.utils import get_incoming_rate
|
||||
|
||||
|
||||
class QtyMismatchError(ValidationError):
|
||||
@@ -180,7 +180,7 @@ class BuyingController(SubcontractingController):
|
||||
for row in self.items:
|
||||
if row.rate <= 0:
|
||||
# override the rate with valuation rate
|
||||
row.rate = _get_incoming_rate(
|
||||
row.rate = get_incoming_rate(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.warehouse,
|
||||
@@ -664,7 +664,7 @@ class BuyingController(SubcontractingController):
|
||||
if not posting_time:
|
||||
posting_time = nowtime()
|
||||
|
||||
outgoing_rate = _get_incoming_rate(
|
||||
outgoing_rate = get_incoming_rate(
|
||||
{
|
||||
"item_code": d.item_code,
|
||||
"warehouse": d.get("from_warehouse"),
|
||||
|
||||
@@ -40,11 +40,6 @@ def get_variant(
|
||||
:param item: Template Item
|
||||
:param args: A dictionary with "Attribute" as key and "Attribute Value" as value
|
||||
"""
|
||||
# The client callers are the Item form (item.js:1144, 1483), so the template Item is the boundary
|
||||
# and `read` is loser-free: the roles that cannot read Item cannot open that form either. The two
|
||||
# server-side callers (item.py:1083 on Item save, item_variant.py:393) already hold the template.
|
||||
frappe.has_permission("Item", doc=template, throw=True)
|
||||
|
||||
item_template = frappe.get_doc("Item", template)
|
||||
|
||||
if item_template.variant_based_on == "Manufacturer" and manufacturer:
|
||||
@@ -323,12 +318,6 @@ def find_variant(template, args, variant_item_code=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_variant(item: str, args: dict | str, use_template_image: bool = False):
|
||||
# Same right its sibling enqueue_multiple_variant_creation already requires — this builds an
|
||||
# Item the caller is about to insert (item.js:1511) — plus record-level read on the template
|
||||
# it copies from.
|
||||
frappe.has_permission("Item", ptype="create", throw=True)
|
||||
frappe.has_permission("Item", doc=item, throw=True)
|
||||
|
||||
use_template_image = frappe.parse_json(use_template_image)
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
@@ -353,7 +342,7 @@ def create_variant(item: str, args: dict | str, use_template_image: bool = False
|
||||
return variant
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def enqueue_multiple_variant_creation(item: str, args: dict | str, use_template_image: bool = False):
|
||||
use_template_image = frappe.parse_json(use_template_image)
|
||||
# There can be innumerable attribute combinations, enqueue
|
||||
@@ -550,10 +539,6 @@ def make_variant_item_code(template_item_code, template_item_name, variant):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_variant_doc_for_quick_entry(template: str, args: dict | str):
|
||||
# Delegates to get_variant and create_variant below, which carry their own checks; this one
|
||||
# fails fast rather than relying on that delegation.
|
||||
frappe.has_permission("Item", doc=template, throw=True)
|
||||
|
||||
variant_based_on = frappe.db.get_value("Item", template, "variant_based_on")
|
||||
args = frappe.parse_json(args)
|
||||
if variant_based_on == "Manufacturer":
|
||||
|
||||
@@ -482,71 +482,49 @@ def get_project_name(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
|
||||
):
|
||||
proj = qb.DocType("Project")
|
||||
meta = frappe.get_meta(doctype)
|
||||
|
||||
list_filters = [["status", "not in", ["Completed", "Cancelled", "On hold"]]]
|
||||
qb_filter_and_conditions = []
|
||||
qb_filter_or_conditions = []
|
||||
|
||||
if filters:
|
||||
if filters.get("customer"):
|
||||
# an `in` containing "" renders as `ifnull(customer,'') in (...)`: this customer, or none
|
||||
list_filters.append(["customer", "in", [filters.get("customer"), ""]])
|
||||
qb_filter_and_conditions.append(
|
||||
(proj.customer == filters.get("customer")) | (proj.customer.isnull()) | (proj.customer == "")
|
||||
)
|
||||
|
||||
if filters.get("company"):
|
||||
list_filters.append(["company", "=", filters.get("company")])
|
||||
qb_filter_and_conditions.append(proj.company == filters.get("company"))
|
||||
|
||||
# don't consider 'customer' and 'status' fields for pattern search, as they must be exactly matched
|
||||
# permlevel fields go too: get_list refuses to filter on one, which would fail the whole call
|
||||
searchfields = [
|
||||
x
|
||||
for x in meta.get_search_fields()
|
||||
if x not in ["customer", "status"] and not (meta.get_field(x) and meta.get_field(x).permlevel)
|
||||
]
|
||||
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
|
||||
|
||||
q = qb.from_(proj)
|
||||
|
||||
fields = get_fields(doctype, ["name", "project_name"])
|
||||
for x in fields:
|
||||
q = q.select(proj[x])
|
||||
|
||||
# get_list applies the doctype check and the caller's record-level conditions
|
||||
if not txt:
|
||||
# no search term means no relevance ordering, so the whole query is expressible here and
|
||||
# stays paginated in SQL rather than materialising every permitted name
|
||||
return frappe.get_list(
|
||||
"Project",
|
||||
filters=list_filters,
|
||||
fields=fields,
|
||||
order_by="idx desc, name",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
# don't consider 'customer' and 'status' fields for pattern search, as they must be exactly matched
|
||||
searchfields = [
|
||||
x for x in frappe.get_meta(doctype).get_search_fields() if x not in ["customer", "status"]
|
||||
]
|
||||
|
||||
# pattern search
|
||||
if txt:
|
||||
for x in searchfields:
|
||||
qb_filter_or_conditions.append(proj[x].like(f"%{txt}%"))
|
||||
|
||||
q = q.where(Criterion.all(qb_filter_and_conditions)).where(Criterion.any(qb_filter_or_conditions))
|
||||
|
||||
# ordering
|
||||
if txt:
|
||||
# project_name containing search string 'txt' will be given higher precedence
|
||||
q = q.orderby(
|
||||
Case()
|
||||
.when(
|
||||
Locate(Lower(txt), Lower(proj.project_name)) > 0,
|
||||
Locate(Lower(txt), Lower(proj.project_name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
|
||||
# with a search term, resolve the (already LIKE-narrowed) permitted names and rank them below:
|
||||
# the relevance ordering is a CASE expression, which `order_by` rejects
|
||||
permitted = frappe.get_list(
|
||||
"Project",
|
||||
filters=list_filters,
|
||||
or_filters=[[x, "like", f"%{txt}%"] for x in searchfields],
|
||||
pluck="name",
|
||||
order_by="",
|
||||
limit_page_length=0,
|
||||
)
|
||||
|
||||
if not permitted:
|
||||
return []
|
||||
|
||||
q = (
|
||||
frappe.qb.from_(proj)
|
||||
.select(*[proj[fieldname] for fieldname in fields])
|
||||
.where(proj.name.isin(permitted))
|
||||
)
|
||||
|
||||
# project_name containing search string 'txt' will be given higher precedence
|
||||
q = q.orderby(
|
||||
Case()
|
||||
.when(
|
||||
Locate(Lower(txt), Lower(proj.project_name)) > 0,
|
||||
Locate(Lower(txt), Lower(proj.project_name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
q = q.orderby(proj.idx, order=Order.desc).orderby(proj.name)
|
||||
|
||||
if page_len:
|
||||
@@ -820,33 +798,28 @@ def get_account_list(
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
bo_filters = [
|
||||
["docstatus", "=", 1],
|
||||
["blanket_order_type", "=", filters.get("blanket_order_type")],
|
||||
["company", "=", filters.get("company")],
|
||||
]
|
||||
bo = frappe.qb.DocType("Blanket Order")
|
||||
bo_item = frappe.qb.DocType("Blanket Order Item")
|
||||
|
||||
if frappe.has_permission("Blanket Order", "read"):
|
||||
bo_filters.append(["Blanket Order Item", "item_code", "=", filters.get("item")])
|
||||
else:
|
||||
parents = frappe.get_all(
|
||||
"Blanket Order Item",
|
||||
filters={"item_code": filters.get("item"), "parenttype": "Blanket Order"},
|
||||
pluck="parent",
|
||||
distinct=True,
|
||||
query = (
|
||||
frappe.qb.from_(bo)
|
||||
.from_(bo_item)
|
||||
.select(bo.name)
|
||||
.distinct()
|
||||
.select(bo.blanket_order_type, bo.to_date)
|
||||
.where(
|
||||
(bo_item.parent == bo.name)
|
||||
& (bo_item.item_code == filters.get("item"))
|
||||
& (bo.blanket_order_type == filters.get("blanket_order_type"))
|
||||
& (bo.company == filters.get("company"))
|
||||
& (bo.docstatus == 1)
|
||||
)
|
||||
bo_filters.append(["name", "in", parents or [""]])
|
||||
)
|
||||
|
||||
if currency := filters.get("currency"):
|
||||
bo_filters.append(["currency", "=", currency])
|
||||
query = query.where(bo.currency == currency)
|
||||
|
||||
return frappe.get_list(
|
||||
"Blanket Order",
|
||||
filters=bo_filters,
|
||||
fields=["name", "blanket_order_type", "to_date"],
|
||||
group_by="name",
|
||||
as_list=True,
|
||||
)
|
||||
return query.run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1042,22 +1015,21 @@ def get_doctype_wise_filters(filters):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_batch_numbers(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
# get_list applies the select check and the caller's record-level conditions together
|
||||
batch_filters = [["disabled", "=", 0], ["name", "like", f"%{txt}%"]]
|
||||
batch = frappe.qb.DocType("Batch")
|
||||
query = (
|
||||
frappe.qb.from_(batch)
|
||||
.select(batch.batch_id)
|
||||
.where(
|
||||
(batch.disabled == 0)
|
||||
& (batch.expiry_date.isnull() | (batch.expiry_date >= today()))
|
||||
& batch.name.like(f"%{txt}%")
|
||||
)
|
||||
)
|
||||
|
||||
if filters and filters.get("item"):
|
||||
batch_filters.append(["item", "=", filters.get("item")])
|
||||
query = query.where(batch.item == filters.get("item"))
|
||||
|
||||
return frappe.get_list(
|
||||
"Batch",
|
||||
filters=batch_filters,
|
||||
or_filters=[["expiry_date", "is", "not set"], ["expiry_date", ">=", today()]],
|
||||
fields=["batch_id"],
|
||||
order_by="batch_id",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
)
|
||||
return query.orderby(batch.batch_id).limit(page_len).offset(start).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1084,71 +1056,41 @@ def item_manufacturer_query(
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_purchase_receipts(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
pr_filters = [["docstatus", "=", 1], ["name", "like", f"%{txt}%"]]
|
||||
pr = frappe.qb.DocType("Purchase Receipt")
|
||||
pr_item = frappe.qb.DocType("Purchase Receipt Item")
|
||||
query = (
|
||||
frappe.qb.from_(pr)
|
||||
.inner_join(pr_item)
|
||||
.on(pr_item.parent == pr.name)
|
||||
.select(pr.name)
|
||||
.distinct() # one row per receipt, not per matching item line
|
||||
.where((pr.docstatus == 1) & pr.name.like(f"%{txt}%"))
|
||||
)
|
||||
|
||||
if filters and filters.get("item_code"):
|
||||
if frappe.has_permission("Purchase Receipt", "read"):
|
||||
# one indexed join, deduped by group_by below
|
||||
pr_filters.append(["Purchase Receipt Item", "item_code", "=", filters.get("item_code")])
|
||||
else:
|
||||
# a select-only caller may use this picker but may not filter on a child table, so resolve
|
||||
# the parents separately rather than losing the filter to a PermissionError
|
||||
parents = frappe.get_all(
|
||||
"Purchase Receipt Item",
|
||||
filters={"item_code": filters.get("item_code"), "parenttype": "Purchase Receipt"},
|
||||
pluck="parent",
|
||||
distinct=True,
|
||||
)
|
||||
pr_filters.append(["name", "in", parents or [""]])
|
||||
query = query.where(pr_item.item_code == filters.get("item_code"))
|
||||
|
||||
# get_list applies the select check and the caller's record-level conditions together.
|
||||
# group_by, not distinct: it dedupes the child join just the same, and frappe drops ORDER BY
|
||||
# from a distinct query on Postgres
|
||||
return frappe.get_list(
|
||||
"Purchase Receipt",
|
||||
filters=pr_filters,
|
||||
fields=["name"],
|
||||
group_by="name",
|
||||
order_by="name",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
)
|
||||
return query.orderby(pr.name).limit(page_len).offset(start).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_purchase_invoices(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
pi_filters = [["docstatus", "=", 1], ["name", "like", f"%{txt}%"]]
|
||||
pi = frappe.qb.DocType("Purchase Invoice")
|
||||
pi_item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
query = (
|
||||
frappe.qb.from_(pi)
|
||||
.inner_join(pi_item)
|
||||
.on(pi_item.parent == pi.name)
|
||||
.select(pi.name)
|
||||
.distinct() # one row per invoice, not per matching item line
|
||||
.where((pi.docstatus == 1) & pi.name.like(f"%{txt}%"))
|
||||
)
|
||||
|
||||
if filters and filters.get("item_code"):
|
||||
if frappe.has_permission("Purchase Invoice", "read"):
|
||||
# one indexed join, deduped by group_by below
|
||||
pi_filters.append(["Purchase Invoice Item", "item_code", "=", filters.get("item_code")])
|
||||
else:
|
||||
# a select-only caller may use this picker but may not filter on a child table, so resolve
|
||||
# the parents separately rather than losing the filter to a PermissionError
|
||||
parents = frappe.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"item_code": filters.get("item_code"), "parenttype": "Purchase Invoice"},
|
||||
pluck="parent",
|
||||
distinct=True,
|
||||
)
|
||||
pi_filters.append(["name", "in", parents or [""]])
|
||||
query = query.where(pi_item.item_code == filters.get("item_code"))
|
||||
|
||||
# get_list applies the select check and the caller's record-level conditions together.
|
||||
# group_by, not distinct: it dedupes the child join just the same, and frappe drops ORDER BY
|
||||
# from a distinct query on Postgres
|
||||
return frappe.get_list(
|
||||
"Purchase Invoice",
|
||||
filters=pi_filters,
|
||||
fields=["name"],
|
||||
group_by="name",
|
||||
order_by="name",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
)
|
||||
return query.orderby(pi.name).limit(page_len).offset(start).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1235,30 +1177,9 @@ def get_payment_terms_for_references(
|
||||
):
|
||||
terms = []
|
||||
if filters:
|
||||
reference = filters.get("reference")
|
||||
if not reference:
|
||||
return terms
|
||||
|
||||
# only a plain name names one document: a filter operator (["like", "%"], ["!=", ""]) would
|
||||
# widen this past the document the caller named, and past the one being authorised below
|
||||
if not isinstance(reference, str):
|
||||
frappe.throw(_("Invalid reference"), frappe.PermissionError)
|
||||
|
||||
# Payment Schedule is a child table and carries no permissions of its own, so the
|
||||
# document the schedule belongs to is what decides access to these rows
|
||||
# prefer the caller's own reference type; the lookup below cannot tell two parents of
|
||||
# different types apart when they share a name
|
||||
parenttype = filters.get("reference_doctype") or frappe.db.get_value(
|
||||
"Payment Schedule", {"parent": reference}, "parenttype"
|
||||
)
|
||||
if not parenttype:
|
||||
return terms
|
||||
|
||||
frappe.has_permission(parenttype, doc=reference, throw=True)
|
||||
|
||||
terms = frappe.db.get_all(
|
||||
"Payment Schedule",
|
||||
filters={"parent": reference, "parenttype": parenttype},
|
||||
filters={"parent": filters.get("reference")},
|
||||
fields=["payment_term"],
|
||||
limit=page_len,
|
||||
as_list=1,
|
||||
@@ -1271,31 +1192,6 @@ def get_payment_terms_for_references(
|
||||
def get_filtered_child_rows(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict
|
||||
):
|
||||
parent = filters.get("parent") if filters else None
|
||||
|
||||
# a plain name, never a filter operator: ["like", "%"] here would span parents, and only one
|
||||
# of them would be the document authorised below
|
||||
if not parent or not isinstance(parent, str):
|
||||
frappe.throw(_("Parent document is required to search child rows"), frappe.PermissionError)
|
||||
|
||||
# `doctype` is caller supplied, so it has to be a child table before it is worth checking:
|
||||
# any other doctype would put the caller's filters on a table this query never meant to read
|
||||
if not frappe.get_meta(doctype).istable:
|
||||
frappe.throw(_("{0} is not a child table").format(doctype), frappe.PermissionError)
|
||||
|
||||
# child tables carry no permissions of their own, so the document the rows hang off is what
|
||||
# decides access. Read the parent type off the rows rather than off `filters`, so that the
|
||||
# document being authorised is always the one being returned.
|
||||
parenttype = frappe.db.get_value(doctype, {"parent": parent}, "parenttype")
|
||||
|
||||
if not parenttype or not frappe.db.exists(parenttype, parent):
|
||||
return []
|
||||
|
||||
frappe.has_permission(doctype, parent_doctype=parenttype, throw=True)
|
||||
|
||||
# and on the parent record itself, so that User Permissions still apply
|
||||
frappe.has_permission(parenttype, doc=parent, throw=True)
|
||||
|
||||
table = frappe.qb.DocType(doctype)
|
||||
query = (
|
||||
frappe.get_query(table, filters=filters)
|
||||
@@ -1321,11 +1217,7 @@ def get_filtered_child_rows(
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_item_uom_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
if frappe.get_single_value("Stock Settings", "allow_uom_with_conversion_rate_defined_in_item"):
|
||||
item_code = filters.get("item_code")
|
||||
if not item_code or not frappe.get_list("Item", filters=[["name", "=", item_code]], pluck="name"):
|
||||
return []
|
||||
|
||||
query_filters = {"parent": item_code, "parenttype": "Item"}
|
||||
query_filters = {"parent": filters.get("item_code")}
|
||||
|
||||
if txt:
|
||||
query_filters["uom"] = ["like", f"%{txt}%"]
|
||||
@@ -1340,7 +1232,7 @@ def get_item_uom_query(doctype: str, txt: str, searchfield: str, start: int, pag
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
return frappe.get_list(
|
||||
return frappe.get_all(
|
||||
"UOM",
|
||||
filters={"name": ["like", f"%{txt}%"], "enabled": 1},
|
||||
fields=["name"],
|
||||
|
||||
@@ -12,7 +12,7 @@ from frappe.utils import cint, flt, format_datetime, get_datetime
|
||||
|
||||
import erpnext
|
||||
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
|
||||
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method, getdate
|
||||
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method, getdate
|
||||
|
||||
|
||||
class StockOverReturnError(frappe.ValidationError):
|
||||
@@ -821,7 +821,7 @@ def get_rate_for_return(
|
||||
rate = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "incoming_rate")
|
||||
|
||||
if rate is None and sle:
|
||||
rate = _get_incoming_rate(
|
||||
rate = get_incoming_rate(
|
||||
{
|
||||
"item_code": sle.item_code,
|
||||
"warehouse": sle.warehouse,
|
||||
@@ -1309,38 +1309,14 @@ def get_available_serial_nos(serial_nos, warehouse):
|
||||
)
|
||||
|
||||
|
||||
# the only doctypes these endpoints are called for; both reach get_value()/get_all() as the doctype itself
|
||||
RETURNABLE_INVOICE_DOCTYPES = ("Sales Invoice", "POS Invoice")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_data(invoice: str):
|
||||
# `invoice` may be either a Sales Invoice or a POS Invoice — both share the Sales Invoice
|
||||
# Payment child table — so resolve which one it is before authorising rather than guessing.
|
||||
parenttype = frappe.db.get_value("Sales Invoice Payment", {"parent": invoice}, "parenttype")
|
||||
if not parenttype:
|
||||
return []
|
||||
|
||||
if parenttype not in RETURNABLE_INVOICE_DOCTYPES:
|
||||
frappe.throw(_("Invalid document type"), frappe.PermissionError)
|
||||
|
||||
frappe.has_permission(parenttype, doc=invoice, throw=True)
|
||||
|
||||
payment = frappe.db.get_all("Sales Invoice Payment", {"parent": invoice}, ["mode_of_payment", "amount"])
|
||||
return payment
|
||||
|
||||
|
||||
def validate_returnable_invoice(doctype: str, invoice: str) -> None:
|
||||
if doctype not in RETURNABLE_INVOICE_DOCTYPES:
|
||||
frappe.throw(_("Invalid document type"), frappe.PermissionError)
|
||||
|
||||
frappe.has_permission(doctype, doc=invoice, throw=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_invoice_item_returned_qty(doctype: str, invoice: str, customer: str, item_row_name: str):
|
||||
validate_returnable_invoice(doctype, invoice)
|
||||
|
||||
is_return, docstatus = frappe.db.get_value(doctype, invoice, ["is_return", "docstatus"])
|
||||
if not is_return and docstatus == 1:
|
||||
return get_returned_qty_map_for_row(invoice, customer, item_row_name, doctype)
|
||||
@@ -1348,8 +1324,6 @@ def get_invoice_item_returned_qty(doctype: str, invoice: str, customer: str, ite
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_invoice_returnable(doctype: str, invoice: str):
|
||||
validate_returnable_invoice(doctype, invoice)
|
||||
|
||||
is_return, docstatus, customer = frappe.db.get_value(
|
||||
doctype, invoice, ["is_return", "docstatus", "customer"]
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, i
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
from erpnext.stock.doctype.item.item import set_item_default
|
||||
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
|
||||
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method
|
||||
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method
|
||||
|
||||
|
||||
class SellingController(StockController):
|
||||
@@ -588,7 +588,7 @@ class SellingController(StockController):
|
||||
and self.get("is_return")
|
||||
)
|
||||
):
|
||||
d.incoming_rate = _get_incoming_rate(
|
||||
d.incoming_rate = get_incoming_rate(
|
||||
{
|
||||
"item_code": d.item_code,
|
||||
"warehouse": d.warehouse,
|
||||
|
||||
@@ -21,7 +21,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
|
||||
)
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_nos_from_bundle
|
||||
from erpnext.stock.utils import _get_incoming_rate
|
||||
from erpnext.stock.utils import get_incoming_rate
|
||||
|
||||
|
||||
class SubcontractingController(StockController):
|
||||
@@ -89,7 +89,7 @@ class SubcontractingController(StockController):
|
||||
}
|
||||
)
|
||||
|
||||
rate = _get_incoming_rate(kwargs)
|
||||
rate = get_incoming_rate(kwargs)
|
||||
precision = frappe.get_precision("Subcontracting Receipt Supplied Item", "rate")
|
||||
if flt(rate, precision) != flt(row.rate, precision):
|
||||
row.rate = rate
|
||||
@@ -844,7 +844,7 @@ class SubcontractingController(StockController):
|
||||
args["batch_no"] = rm_obj.batch_no
|
||||
args["serial_no"] = rm_obj.serial_no
|
||||
|
||||
rm_obj.rate = _get_incoming_rate(args)
|
||||
rm_obj.rate = get_incoming_rate(args)
|
||||
|
||||
def __set_batch_nos(self, bom_item, item_row, rm_obj, qty):
|
||||
key = (rm_obj.rm_item_code, item_row.item_code, item_row.get(self.subcontract_data.order_field))
|
||||
|
||||
@@ -400,15 +400,13 @@ def quotation_party_name_expr():
|
||||
|
||||
|
||||
def quotation_territory_expr():
|
||||
"""Territory from the party master. CRM Deal has none here: it ships with the CRM app."""
|
||||
"""Only Customer and Lead carry a territory; other party types have none."""
|
||||
return (
|
||||
"case "
|
||||
"when t1.quotation_to = 'Customer' then "
|
||||
"(select c.territory from `tabCustomer` c where c.name = t1.party_name) "
|
||||
"when t1.quotation_to = 'Lead' then "
|
||||
"(select l.territory from `tabLead` l where l.name = t1.party_name) "
|
||||
"when t1.quotation_to = 'Prospect' then "
|
||||
"(select p.territory from `tabProspect` p where p.name = t1.party_name) "
|
||||
"end"
|
||||
)
|
||||
|
||||
|
||||
@@ -114,16 +114,6 @@ def make_quotation(source_name: str, target_doc: str | dict | Document | None =
|
||||
def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
|
||||
"""raise a issue from email"""
|
||||
|
||||
# `communication` is caller supplied and nothing here checked it. Communication grants read to
|
||||
# `All` only for the owner (if_owner) and carries a has_permission hook, so doc= is what decides
|
||||
# access; the desk button only appears on an email the caller already has open.
|
||||
frappe.has_permission("Communication", doc=communication, throw=True)
|
||||
|
||||
# both paths below end in a Lead. The insert path checks `create` on its own, but the path that
|
||||
# reuses an existing Lead required nothing, so it returned a Lead's name and linked the email
|
||||
# for callers with no access to Leads at all.
|
||||
frappe.has_permission("Lead", ptype="create", throw=True)
|
||||
|
||||
doc = frappe.get_doc("Communication", communication)
|
||||
lead_name = None
|
||||
if doc.sender:
|
||||
|
||||
@@ -130,15 +130,8 @@ def make_opportunity_from_communication(
|
||||
):
|
||||
from erpnext.crm.doctype.lead.mapper import make_lead_from_communication
|
||||
|
||||
# `communication` is caller supplied and nothing checked it. Communication grants read to `All`
|
||||
# only for the owner (if_owner) and carries a has_permission hook, so doc= is what decides
|
||||
# access; the desk button only appears on an email the caller already has open.
|
||||
frappe.has_permission("Communication", doc=communication, throw=True)
|
||||
|
||||
doc = frappe.get_doc("Communication", communication)
|
||||
|
||||
# make_lead_from_communication() carries its own check, but it is skipped entirely when the
|
||||
# email already references a Lead, so this cannot rely on it.
|
||||
lead = doc.reference_name if doc.reference_doctype == "Lead" else None
|
||||
if not lead:
|
||||
lead = make_lead_from_communication(communication, ignore_communication_links=True)
|
||||
|
||||
@@ -150,11 +150,6 @@ def link_open_events(ref_doctype, ref_docname, doc):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_open_activities(ref_doctype: str, ref_docname: str):
|
||||
# both arguments are caller supplied and nothing below checked them: the ToDo and Event rows are
|
||||
# read with get_all, so the referenced document is what decides who may see its activities.
|
||||
# doc= applies User Permissions; the desk only asks this for a form the caller has open.
|
||||
frappe.has_permission(ref_doctype, doc=ref_docname, throw=True)
|
||||
|
||||
tasks = get_open_todos(ref_doctype, ref_docname)
|
||||
events = get_open_events(ref_doctype, ref_docname)
|
||||
tasks_history = get_closed_todos(ref_doctype, ref_docname)
|
||||
|
||||
@@ -20,11 +20,8 @@ class CodeListSelectionMismatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def import_genericode():
|
||||
# check before save(), which only runs after the XML is fetched and parsed; denies exactly who save() would, sooner
|
||||
frappe.has_permission("Code List", "create", throw=True)
|
||||
|
||||
try:
|
||||
content, file_name = get_uploaded_genericode_file()
|
||||
|
||||
@@ -152,7 +149,7 @@ def parse_genericode_content(content: bytes):
|
||||
return etree.fromstring(content, parser=parser)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def process_genericode_import(
|
||||
code_list_name: str,
|
||||
file_name: str,
|
||||
@@ -163,11 +160,6 @@ def process_genericode_import(
|
||||
):
|
||||
from erpnext.edi.doctype.common_code.common_code import import_genericode
|
||||
|
||||
# Same reasoning as above: common_code.save() enforces this per document, but only after the
|
||||
# file has been read and its XML parsed and queried.
|
||||
frappe.has_permission("Common Code", "create", throw=True)
|
||||
frappe.has_permission("Code List", doc=code_list_name, throw=True)
|
||||
|
||||
column_map = {"code": code_column, "title": title_column, "description": description_column}
|
||||
|
||||
return import_genericode(
|
||||
|
||||
@@ -37,12 +37,8 @@ class PlaidSettings(Document):
|
||||
return plaid.get_link_token()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def get_plaid_configuration():
|
||||
# Returns plaid_env and a freshly minted Plaid link_token. Plaid Settings is a System-Manager-only
|
||||
# single doctype and every caller reaches this from its own form, so that is the boundary.
|
||||
frappe.has_permission("Plaid Settings", throw=True)
|
||||
|
||||
if frappe.db.get_single_value("Plaid Settings", "enabled"):
|
||||
plaid_settings = frappe.get_single("Plaid Settings")
|
||||
return {
|
||||
@@ -56,8 +52,6 @@ def get_plaid_configuration():
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def add_institution(token: str, response: str | dict):
|
||||
frappe.has_permission("Plaid Settings", throw=True)
|
||||
|
||||
response = frappe.parse_json(response)
|
||||
|
||||
plaid = PlaidConnector()
|
||||
@@ -87,8 +81,6 @@ def add_institution(token: str, response: str | dict):
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
|
||||
frappe.has_permission("Plaid Settings", throw=True)
|
||||
|
||||
response = frappe.parse_json(response)
|
||||
bank = frappe.parse_json(bank)
|
||||
result = []
|
||||
@@ -336,10 +328,8 @@ def automatic_synchronization():
|
||||
enqueue_synchronization()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def enqueue_synchronization():
|
||||
frappe.has_permission("Plaid Settings", throw=True)
|
||||
|
||||
plaid_accounts = frappe.get_all(
|
||||
"Bank Account", filters={"integration_id": ["!=", ""]}, fields=["name", "bank"]
|
||||
)
|
||||
@@ -352,12 +342,8 @@ def enqueue_synchronization():
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def get_link_token_for_update(access_token: str):
|
||||
# `access_token` is caller-supplied and is used to mint a link token at Plaid, so this creates
|
||||
# state at the provider even though it writes nothing here.
|
||||
frappe.has_permission("Plaid Settings", throw=True)
|
||||
|
||||
plaid = PlaidConnector(access_token)
|
||||
return plaid.get_link_token(update_mode=True)
|
||||
|
||||
@@ -376,10 +362,8 @@ def get_company(bank_account_name):
|
||||
frappe.throw(_("Could not detect the Company for updating Bank Accounts"))
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def update_bank_account_ids(response: str | dict):
|
||||
frappe.has_permission("Plaid Settings", throw=True)
|
||||
|
||||
data = frappe.parse_json(response)
|
||||
institution_name = data["institution"]["name"]
|
||||
bank = frappe.get_doc("Bank", institution_name).as_dict()
|
||||
|
||||
@@ -87,9 +87,6 @@ after_install = "erpnext.setup.install.after_install"
|
||||
after_app_install = "erpnext.setup.install.after_app_install"
|
||||
after_app_uninstall = "erpnext.setup.install.after_app_uninstall"
|
||||
|
||||
# patches that must stop the migration when they fail, even with `bench migrate --skip-failing`
|
||||
never_skip_patches = ["erpnext.patches.v16_0.update_serial_batch_entries"]
|
||||
|
||||
boot_session = "erpnext.startup.boot.boot_session"
|
||||
notification_config = "erpnext.startup.notifications.get_notification_config"
|
||||
get_help_messages = "erpnext.utilities.activation.get_help_messages"
|
||||
|
||||
@@ -15,7 +15,7 @@ from frappe.website.website_generator import WebsiteGenerator
|
||||
|
||||
import erpnext
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
from erpnext.stock.doctype.item.item import _get_item_details
|
||||
from erpnext.stock.doctype.item.item import get_item_details
|
||||
from erpnext.stock.get_item_details import get_conversion_factor, get_price_list_rate
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
@@ -564,7 +564,7 @@ class BOM(WebsiteGenerator):
|
||||
self.manage_default_bom()
|
||||
|
||||
def get_item_det(self, item_code):
|
||||
item = _get_item_details(item_code)
|
||||
item = get_item_details(item_code)
|
||||
|
||||
if not item:
|
||||
frappe.throw(_("Item: {0} does not exist in the system").format(item_code))
|
||||
@@ -972,7 +972,7 @@ class BOM(WebsiteGenerator):
|
||||
def _add_raw_material_row(self, operation_row_id, row):
|
||||
row = parse_json(row)
|
||||
|
||||
row.update(_get_item_details(row.get("item_code")))
|
||||
row.update(get_item_details(row.get("item_code")))
|
||||
row.operation_row_id = operation_row_id
|
||||
|
||||
item_row = self.get_item_data(row.item_code, operation_row_id)
|
||||
|
||||
@@ -14,7 +14,7 @@ from frappe.query_builder import Field
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.stock.doctype.item.item import _get_item_details
|
||||
from erpnext.stock.doctype.item.item import get_item_details
|
||||
|
||||
_BOM_DIFF_IDENTIFIERS = {
|
||||
"operations": "operation",
|
||||
@@ -195,7 +195,7 @@ def make_variant_bom(
|
||||
def _postprocess_variant_bom(source, doc, item, variant_items, source_name):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import add_variant_item
|
||||
|
||||
item_data = _get_item_details(item)
|
||||
item_data = get_item_details(item)
|
||||
doc.item = item
|
||||
doc.quantity = 1
|
||||
doc.update(
|
||||
|
||||
@@ -299,6 +299,7 @@ frappe.ui.form.on("Job Card", {
|
||||
fieldtype: "Float",
|
||||
label: __("Completed Quantity"),
|
||||
fieldname: "completed_qty",
|
||||
reqd: 1,
|
||||
default: pending_qty,
|
||||
change() {
|
||||
const dialog = frm.job_completion_dialog;
|
||||
@@ -423,8 +424,8 @@ frappe.ui.form.on("Job Card", {
|
||||
frm.job_completion_dialog = frappe.prompt(
|
||||
fields,
|
||||
(data) => {
|
||||
if (data.completed_qty < 0) {
|
||||
frappe.throw(__("Completed Quantity cannot be negative"));
|
||||
if (data.qty <= 0) {
|
||||
frappe.throw(__("Quantity should be greater than 0"));
|
||||
}
|
||||
|
||||
frm.call({
|
||||
|
||||
@@ -205,12 +205,10 @@ class JobCard(Document):
|
||||
).format(self.name)
|
||||
)
|
||||
|
||||
if self.docstatus == 1 and not (
|
||||
self.total_completed_qty or self.process_loss_qty or self.pending_qty
|
||||
):
|
||||
if self.docstatus == 1 and not self.total_completed_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Completed, Process Loss or Pending Qty is required for Job Card {0}, please start and complete the job card before submission"
|
||||
"Total Completed Qty is required for Job Card {0}, please start and complete the job card before submission"
|
||||
).format(self.name)
|
||||
)
|
||||
|
||||
@@ -1015,10 +1013,9 @@ class JobCard(Document):
|
||||
|
||||
def set_process_loss(self):
|
||||
precision = self.precision("total_completed_qty")
|
||||
should_set_process_loss = self.total_completed_qty or self.process_loss_qty
|
||||
|
||||
self.process_loss_qty = 0.0
|
||||
if should_set_process_loss and self.for_quantity > self.total_completed_qty:
|
||||
if self.total_completed_qty and self.for_quantity > self.total_completed_qty:
|
||||
self.process_loss_qty = (
|
||||
flt(self.for_quantity, precision)
|
||||
- flt(self.total_completed_qty, precision)
|
||||
@@ -1642,7 +1639,7 @@ class JobCard(Document):
|
||||
row.to_time = kwargs.to_time
|
||||
row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
|
||||
|
||||
if kwargs.get("completed_qty") is not None:
|
||||
if kwargs.completed_qty:
|
||||
row.completed_qty = kwargs.completed_qty
|
||||
row.db_update()
|
||||
else:
|
||||
@@ -1658,7 +1655,7 @@ class JobCard(Document):
|
||||
kwargs.employee = employee.get("employee")
|
||||
if kwargs.from_time and not kwargs.to_time:
|
||||
self.add_new_time_log_for_employee(kwargs)
|
||||
elif not kwargs.from_time and not kwargs.to_time and kwargs.get("completed_qty") is not None:
|
||||
elif not kwargs.from_time and not kwargs.to_time and kwargs.completed_qty:
|
||||
self.update_completed_qty_for_employee(kwargs)
|
||||
update_status = True
|
||||
else:
|
||||
@@ -1668,7 +1665,7 @@ class JobCard(Document):
|
||||
self.set_status(update_status=update_status)
|
||||
|
||||
def add_new_time_log_for_employee(self, kwargs):
|
||||
if kwargs.get("qty") is not None:
|
||||
if kwargs.qty:
|
||||
kwargs.completed_qty = kwargs.qty
|
||||
|
||||
row = self.append("time_logs", kwargs)
|
||||
@@ -1773,9 +1770,6 @@ class JobCard(Document):
|
||||
frappe.throw(_("Submitted Job Card cannot be processed."))
|
||||
|
||||
def validate_complete_job_card_qty(self, kwargs):
|
||||
if flt(kwargs.qty) < 0:
|
||||
frappe.throw(_("Completed quantity cannot be negative."))
|
||||
|
||||
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) < 0:
|
||||
frappe.throw(_("Pending quantity cannot be negative."))
|
||||
|
||||
|
||||
@@ -1330,50 +1330,6 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(flt(job_card.total_completed_qty), 3)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 0)
|
||||
|
||||
def test_completion_allows_zero_completed_qty(self):
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
|
||||
|
||||
job_card = self.get_first_job_card(work_order.name)
|
||||
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=0,
|
||||
for_quantity=5,
|
||||
pending_qty=0,
|
||||
process_loss_qty=5,
|
||||
end_time="2024-03-01 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.total_completed_qty), 0)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 5)
|
||||
|
||||
job_card.submit()
|
||||
self.assertEqual(job_card.docstatus, 1)
|
||||
|
||||
def test_completion_overwrites_existing_completed_qty_with_zero(self):
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
|
||||
|
||||
job_card = self.get_first_job_card(work_order.name)
|
||||
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00", "completed_qty": 5})
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=0,
|
||||
for_quantity=5,
|
||||
pending_qty=0,
|
||||
process_loss_qty=5,
|
||||
end_time="2024-03-01 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.total_completed_qty), 0)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 5)
|
||||
self.assertEqual(flt(job_card.time_logs[0].completed_qty), 0)
|
||||
|
||||
job_card.submit()
|
||||
self.assertEqual(job_card.docstatus, 1)
|
||||
|
||||
def test_completion_qty_keeps_for_quantity_across_cycles(self):
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
|
||||
|
||||
@@ -3468,7 +3424,6 @@ class TestJobCardLogic(ERPNextTestSuite):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.for_quantity = 5
|
||||
jc.validate_complete_job_card_qty(frappe._dict(pending_qty=3)) # within range -> passes
|
||||
self.assertRaises(frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(qty=-1))
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=-1)
|
||||
)
|
||||
@@ -3494,12 +3449,6 @@ class TestJobCardLogic(ERPNextTestSuite):
|
||||
jc.validate_complete_job_card_qty(
|
||||
frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0)
|
||||
)
|
||||
jc.validate_complete_job_card_qty(
|
||||
frappe._dict(for_quantity=5, qty=0, pending_qty=0, process_loss_qty=5)
|
||||
)
|
||||
jc.validate_complete_job_card_qty(
|
||||
frappe._dict(for_quantity=5, qty=0, pending_qty=5, process_loss_qty=0)
|
||||
)
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
@@ -3534,19 +3483,6 @@ class TestJobCardLogic(ERPNextTestSuite):
|
||||
nothing_done.set_process_loss()
|
||||
self.assertEqual(nothing_done.process_loss_qty, 0)
|
||||
|
||||
all_process_loss = frappe.new_doc("Job Card")
|
||||
all_process_loss.for_quantity = 10
|
||||
all_process_loss.process_loss_qty = 10
|
||||
all_process_loss.set_process_loss()
|
||||
self.assertEqual(all_process_loss.process_loss_qty, 10)
|
||||
|
||||
def test_zero_completed_qty_is_valid_for_semi_finished_goods(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.docstatus = 1
|
||||
jc.track_semi_finished_goods = 1
|
||||
jc.process_loss_qty = 5
|
||||
jc.validate_semi_finished_goods()
|
||||
|
||||
def test_capacity_overlap_detection(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
sequential = [
|
||||
|
||||
@@ -152,7 +152,6 @@ def get_items_for_material_requests(
|
||||
frappe.has_permission("Production Plan", "read", throw=True)
|
||||
|
||||
doc = _normalize_mr_doc(doc)
|
||||
_authorize_mr_request(doc, warehouses)
|
||||
_validate_group_warehouse_target(doc)
|
||||
warehouses = _filter_warehouses(doc, warehouses, get_parent_warehouse_data)
|
||||
doc["mr_items"] = []
|
||||
@@ -181,56 +180,6 @@ def _normalize_mr_doc(doc):
|
||||
return doc
|
||||
|
||||
|
||||
def _authorize_mr_request(doc, warehouses=None):
|
||||
"""Scope a caller-supplied plan to what the caller may see; `doc` is often unsaved, so check only a real name."""
|
||||
name = doc.get("name")
|
||||
if isinstance(name, str) and frappe.db.exists("Production Plan", name):
|
||||
frappe.has_permission("Production Plan", doc=name, throw=True)
|
||||
|
||||
# Every value below arrives through frappe.parse_json, so container elements are untyped: a dict
|
||||
# in any of these reaches frappe.db.get_value() in its *name* position and becomes a filter.
|
||||
for row in _iter_mr_rows(doc):
|
||||
for fieldname in ("item_code", "warehouse", "bom_no", "sales_order", "uom", "purchase_uom"):
|
||||
value = row.get(fieldname)
|
||||
if value is not None and not isinstance(value, str):
|
||||
frappe.throw(_("Invalid {0}").format(fieldname), frappe.PermissionError)
|
||||
|
||||
# The warehouse — not `company` — is what selects whose stock figures come back, so that is what
|
||||
# a Company User Permission has to be applied to. Costs nobody who holds no such permission.
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Production Plan")
|
||||
if not allowed_companies:
|
||||
return
|
||||
|
||||
for warehouse in _iter_mr_warehouses(doc, warehouses):
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
if company and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
|
||||
def _iter_mr_rows(doc):
|
||||
for key in ("po_items", "items", "sub_assembly_items"):
|
||||
for row in doc.get(key) or []:
|
||||
if isinstance(row, dict):
|
||||
yield row
|
||||
|
||||
|
||||
def _iter_mr_warehouses(doc, warehouses):
|
||||
seen = set()
|
||||
for value in (doc.get("for_warehouse"), doc.get("warehouse")):
|
||||
if isinstance(value, str) and value:
|
||||
seen.add(value)
|
||||
for row in _iter_mr_rows(doc):
|
||||
value = row.get("warehouse")
|
||||
if isinstance(value, str) and value:
|
||||
seen.add(value)
|
||||
for value in get_warehouse_list(warehouses) if warehouses else []:
|
||||
if isinstance(value, str) and value:
|
||||
seen.add(value)
|
||||
return seen
|
||||
|
||||
|
||||
def _validate_group_warehouse_target(doc):
|
||||
# the group only scopes availability; raw materials still need a concrete
|
||||
# receiving warehouse, so for_warehouse is required once we generate items.
|
||||
|
||||
@@ -63,25 +63,8 @@ def get_operations(doctype: str, txt: str, searchfield: str, start: int, page_le
|
||||
if txt:
|
||||
query_filters = {"operation": ["like", f"%{txt}%"]}
|
||||
|
||||
if routing := filters.get("routing"):
|
||||
if not frappe.db.exists("Routing", routing):
|
||||
return []
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm("Routing") else "read"
|
||||
frappe.has_permission("Routing", ptype, doc=routing, throw=True)
|
||||
query_filters["parent"] = routing
|
||||
query_filters["parenttype"] = "Routing"
|
||||
else:
|
||||
parents = []
|
||||
for parenttype in ("Routing", "BOM"):
|
||||
ptype = "select" if frappe.only_has_select_perm(parenttype) else "read"
|
||||
if frappe.has_permission(parenttype, ptype):
|
||||
parents += frappe.get_list(parenttype, pluck="name")
|
||||
|
||||
if not parents:
|
||||
return []
|
||||
|
||||
query_filters["parent"] = ["in", parents]
|
||||
if filters.get("routing"):
|
||||
query_filters["parent"] = filters.get("routing")
|
||||
|
||||
return frappe.get_all(
|
||||
"BOM Operation",
|
||||
|
||||
@@ -3414,68 +3414,6 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
self.assertEqual(flt(disassembly_row.transfer_qty), expected_disassembly_qty)
|
||||
disassembly.submit()
|
||||
|
||||
def test_disassembly_representative_is_stable_when_entries_share_a_creation(self):
|
||||
"""The representative line must be decided by the query, not by row order on disk.
|
||||
|
||||
When two Manufacture entries share a creation timestamp the entry name decides, as it always
|
||||
did -- but compared in Python, so the database's collation does not.
|
||||
"""
|
||||
from erpnext.stock.doctype.stock_entry.services.disassemble import DisassembleStockEntry
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
raw_item = make_item("Test RM for Disassembly Tie", {"is_stock_item": 1}).name
|
||||
fg_item = make_item("Test FG for Disassembly Tie", {"is_stock_item": 1}).name
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)
|
||||
|
||||
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
|
||||
make_stock_entry_test_record(
|
||||
item_code=raw_item,
|
||||
purpose="Material Receipt",
|
||||
target=wo.wip_warehouse,
|
||||
qty=50,
|
||||
basic_rate=100,
|
||||
)
|
||||
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
|
||||
for item in transfer.items:
|
||||
item.s_warehouse = wo.wip_warehouse
|
||||
transfer.save()
|
||||
transfer.submit()
|
||||
|
||||
first = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 5))
|
||||
first.submit()
|
||||
second = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 5))
|
||||
second.submit()
|
||||
|
||||
shared_creation = frappe.db.get_value("Stock Entry", first.name, "creation")
|
||||
frappe.db.set_value("Stock Entry", second.name, "creation", shared_creation, update_modified=False)
|
||||
|
||||
rows = frappe.get_all(
|
||||
"Stock Entry Detail",
|
||||
filters={"parent": ("in", [first.name, second.name]), "item_code": raw_item},
|
||||
fields=["name", "parent", "idx"],
|
||||
order_by="name",
|
||||
)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(len({row.idx for row in rows}), 1, "idx must repeat for the tie to matter")
|
||||
|
||||
# mark the two lines apart on a column the representative alone supplies
|
||||
warehouses = {}
|
||||
for offset, row in enumerate(rows):
|
||||
warehouse = create_warehouse(f"_Test Disassembly Tie {offset}")
|
||||
warehouses[row.parent] = warehouse
|
||||
frappe.db.set_value(
|
||||
"Stock Entry Detail", row.name, "s_warehouse", warehouse, update_modified=False
|
||||
)
|
||||
|
||||
service = DisassembleStockEntry(frappe._dict(work_order=wo.name, source_stock_entry=None))
|
||||
source_row = next(
|
||||
row for row in service.get_items_from_manufacture_stock_entry() if row.item_code == raw_item
|
||||
)
|
||||
|
||||
self.assertEqual(source_row.s_warehouse, warehouses[min(warehouses, key=str.casefold)])
|
||||
|
||||
def test_disassembly_with_additional_rm_not_in_bom(self):
|
||||
"""
|
||||
Test that SE-linked disassembly includes additional raw materials
|
||||
|
||||
@@ -1087,14 +1087,6 @@ class WorkOrder(Document):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_bom_operations(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
parent = filters.get("parent")
|
||||
parenttype = filters.get("parenttype") or "BOM"
|
||||
if not parent or not frappe.db.exists(parenttype, parent):
|
||||
return []
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm(parenttype) else "read"
|
||||
frappe.has_permission(parenttype, ptype, doc=parent, throw=True)
|
||||
|
||||
if txt:
|
||||
filters["operation"] = ("like", "%%%s%%" % txt)
|
||||
|
||||
@@ -1140,15 +1132,14 @@ def get_default_warehouse(company: str):
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def stop_unstop(work_order: str, status: str):
|
||||
"""Called from client side on Stop/Unstop event"""
|
||||
|
||||
frappe.has_permission("Work Order", "write", throw=True)
|
||||
if not frappe.has_permission("Work Order", "write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
# the check above is doctype level and never consults User Permissions, so on its own it lets
|
||||
# a caller restricted to one company stop another company's orders
|
||||
pro_order = frappe.get_doc("Work Order", work_order, check_permission="write")
|
||||
pro_order = frappe.get_doc("Work Order", work_order)
|
||||
|
||||
if pro_order.status == "Closed":
|
||||
frappe.throw(_("Closed Work Order can not be stopped or Re-opened"))
|
||||
@@ -1179,12 +1170,12 @@ def query_sales_order(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def close_work_order(work_order: str, status: str):
|
||||
frappe.has_permission("Work Order", "write", throw=True)
|
||||
if not frappe.has_permission("Work Order", "write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
# doctype level above, record level here — see stop_unstop()
|
||||
work_order = frappe.get_doc("Work Order", work_order, check_permission="write")
|
||||
work_order = frappe.get_doc("Work Order", work_order)
|
||||
if work_order.get("operations"):
|
||||
job_cards = frappe.get_list(
|
||||
"Job Card",
|
||||
|
||||
@@ -418,36 +418,34 @@ def get_workstations(**kwargs):
|
||||
frappe.has_permission("Workstation", "read", throw=True)
|
||||
|
||||
kwargs = frappe._dict(kwargs)
|
||||
_workstation = frappe.qb.DocType("Workstation")
|
||||
|
||||
if not kwargs.plant_floor:
|
||||
# The query this replaced compared `plant_floor` against the argument with `=`, which no
|
||||
# row satisfies when it is empty; `get_list` would read the same filter as `IS NULL` and
|
||||
# start returning floor-less workstations. Keep the original contract.
|
||||
return []
|
||||
|
||||
# A list of filters, not a dict: `workstation` and `workstation_name` both constrain `name`
|
||||
# and a dict would silently drop the first of them.
|
||||
filters = [["plant_floor", "=", kwargs.plant_floor], ["disabled", "=", 0]]
|
||||
query = (
|
||||
frappe.qb.from_(_workstation)
|
||||
.select(
|
||||
_workstation.name,
|
||||
_workstation.description,
|
||||
_workstation.status,
|
||||
_workstation.on_status_image,
|
||||
_workstation.off_status_image,
|
||||
)
|
||||
.orderby(_workstation.creation, _workstation.workstation_type, _workstation.name)
|
||||
.where((_workstation.plant_floor == kwargs.plant_floor) & (_workstation.disabled == 0))
|
||||
)
|
||||
|
||||
if kwargs.workstation:
|
||||
filters.append(["name", "=", kwargs.workstation])
|
||||
query = query.where(_workstation.name == kwargs.workstation)
|
||||
|
||||
if kwargs.workstation_type:
|
||||
filters.append(["workstation_type", "=", kwargs.workstation_type])
|
||||
query = query.where(_workstation.workstation_type == kwargs.workstation_type)
|
||||
|
||||
if kwargs.workstation_status:
|
||||
filters.append(["status", "=", kwargs.workstation_status])
|
||||
query = query.where(_workstation.status == kwargs.workstation_status)
|
||||
|
||||
if kwargs.workstation_name:
|
||||
filters.append(["name", "=", kwargs.workstation_name])
|
||||
query = query.where(_workstation.name == kwargs.workstation_name)
|
||||
|
||||
# get_list, not get_all: it applies the caller's User Permissions to rows the doctype check does not scope
|
||||
data = frappe.get_list(
|
||||
"Workstation",
|
||||
filters=filters,
|
||||
fields=["name", "description", "status", "on_status_image", "off_status_image"],
|
||||
order_by="creation, workstation_type, name",
|
||||
)
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
color_map = get_color_map()
|
||||
|
||||
|
||||
@@ -229,10 +229,11 @@ def get_bom_data(filters):
|
||||
bom_item = frappe.qb.DocType(bom_item_table)
|
||||
stock_qty = get_stock_qty_by_item(filters).as_("stock_qty")
|
||||
|
||||
base = frappe.qb.from_(bom_item)
|
||||
base = base.join(stock_qty) if filters.get("warehouse") else base.left_join(stock_qty)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.left_join(stock_qty)
|
||||
.on(bom_item.item_code == stock_qty.item_code)
|
||||
base.on(bom_item.item_code == stock_qty.item_code)
|
||||
.select(
|
||||
bom_item.item_code,
|
||||
# non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid
|
||||
@@ -321,11 +322,31 @@ def get_manufacturer_records():
|
||||
def get_producible_fg_items(filters):
|
||||
BOM_ITEM = frappe.qb.DocType("BOM Item")
|
||||
BOM = frappe.qb.DocType("BOM")
|
||||
BIN = frappe.qb.DocType("Bin")
|
||||
WH = frappe.qb.DocType("Warehouse")
|
||||
|
||||
if not filters.get("warehouse"):
|
||||
warehouse = filters.get("warehouse")
|
||||
if not warehouse:
|
||||
frappe.throw(_("Warehouse is required to get producible FG Items"))
|
||||
|
||||
bin_subquery = get_stock_qty_by_item(filters).as_("stock_qty")
|
||||
warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1)
|
||||
|
||||
if warehouse_details:
|
||||
bin_subquery = (
|
||||
frappe.qb.from_(BIN)
|
||||
.join(WH)
|
||||
.on(BIN.warehouse == WH.name)
|
||||
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
|
||||
.where((WH.lft >= warehouse_details.lft) & (WH.rgt <= warehouse_details.rgt))
|
||||
.groupby(BIN.item_code)
|
||||
)
|
||||
else:
|
||||
bin_subquery = (
|
||||
frappe.qb.from_(BIN)
|
||||
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
|
||||
.where(BIN.warehouse == warehouse)
|
||||
.groupby(BIN.item_code)
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(BOM_ITEM)
|
||||
|
||||
@@ -84,29 +84,6 @@ class TestBOMStockAnalysis(ERPNextTestSuite):
|
||||
)
|
||||
self.assertEqual(footer.get("description"), expected_min)
|
||||
|
||||
def test_components_without_stock_in_selected_warehouse_remain_visible(self):
|
||||
group = create_warehouse("_Test BOM Stock Analysis Group", {"is_group": 1})
|
||||
warehouse = create_warehouse("_Test BOM Stock Analysis Stores", {"parent_warehouse": group})
|
||||
stocked_item, missing_item = self.rm_items
|
||||
create_stock_reconciliation(item_code=stocked_item, warehouse=warehouse, qty=100, rate=100)
|
||||
self.assertFalse(frappe.db.exists("Bin", {"item_code": missing_item, "warehouse": warehouse}))
|
||||
|
||||
for selected_warehouse in (warehouse, group):
|
||||
for exploded in (False, True):
|
||||
with self.subTest(warehouse=selected_warehouse, exploded=exploded):
|
||||
items, footer = run_report(self.boms[0].name, selected_warehouse, exploded, qty_to_make=1)
|
||||
self.assertEqual(set(items), {stocked_item, missing_item})
|
||||
self.assertEqual(items[stocked_item]["available_qty"], fmt_qty(100))
|
||||
self.assertEqual(items[missing_item]["available_qty"], fmt_qty(0))
|
||||
self.assertEqual(items[missing_item]["required_qty"], fmt_qty(10))
|
||||
self.assertEqual(items[missing_item]["difference_qty"], fmt_qty(-10))
|
||||
self.assertEqual(footer["description"], 0)
|
||||
|
||||
items, footer = run_report(self.boms[0].name, warehouse, exploded=False, qty_to_make=0)
|
||||
self.assertEqual(set(items), {stocked_item, missing_item})
|
||||
self.assertEqual(items[missing_item]["available_qty"], fmt_qty(0))
|
||||
self.assertEqual(footer["description"], 0)
|
||||
|
||||
def _build_duplicate_component_bom(self, phantom_first):
|
||||
"""Parent BOM that lists one `component` twice, once via a phantom sub-BOM and once via a
|
||||
non-phantom sub-BOM. `phantom_first` controls which line is at idx 1. Returns the names of
|
||||
@@ -210,18 +187,6 @@ class TestBOMStockAnalysis(ERPNextTestSuite):
|
||||
self.assertAlmostEqual(flt(rows[0].actual_qty), 10.0, places=6)
|
||||
|
||||
|
||||
def run_report(bom, warehouse, exploded, qty_to_make):
|
||||
"""Component rows keyed by item code, plus the footer row."""
|
||||
filters = {
|
||||
"bom": bom,
|
||||
"warehouse": warehouse,
|
||||
"show_exploded_view": exploded,
|
||||
"qty_to_make": qty_to_make,
|
||||
}
|
||||
data, footer = split_data_and_footer(bom_stock_analysis_report(filters)[1])
|
||||
return {row["item"]: row for row in data}, footer
|
||||
|
||||
|
||||
def split_data_and_footer(raw_data):
|
||||
"""Separate component rows from the footer row. Skips blank spacer rows."""
|
||||
data = [row for row in raw_data if row and not row.get("bold")]
|
||||
|
||||
@@ -128,150 +128,53 @@ frappe.query_reports["Material Requirements Planning Report"] = {
|
||||
|
||||
onload(report) {
|
||||
report.page.add_inner_button(__("Make Purchase / Work Order"), () => {
|
||||
const indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
const selected_rows = indexes
|
||||
.map((i) => frappe.query_report.data[i])
|
||||
.filter((row) => row && row.item_code);
|
||||
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
let selected_rows = indexes.map((i) => frappe.query_report.data[i]);
|
||||
|
||||
if (!selected_rows.length) {
|
||||
frappe.throw(__("Please select a row to create a Reposting Entry"));
|
||||
}
|
||||
} else {
|
||||
let show_in_bucket_view = frappe.query_report.get_filter_value("show_in_bucket_view");
|
||||
if (show_in_bucket_view) {
|
||||
frappe.throw(__("Please uncheck 'Show in Bucket View' to create Orders"));
|
||||
}
|
||||
|
||||
if (frappe.query_report.get_filter_value("show_in_bucket_view")) {
|
||||
frappe.throw(__("Please uncheck 'Show in Bucket View' to create Orders"));
|
||||
frappe.prompt(
|
||||
[
|
||||
{
|
||||
fieldname: "use_default_warehouse",
|
||||
label: __("Use Default Warehouse"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
label: __("Warehouse"),
|
||||
fieldtype: "Link",
|
||||
options: "Warehouse",
|
||||
depends_on: "eval:!doc.use_default_warehouse",
|
||||
mandatory_depends_on: "eval:!doc.use_default_warehouse",
|
||||
},
|
||||
],
|
||||
(prompt_data) => {
|
||||
frappe.call({
|
||||
method: "erpnext.manufacturing.report.material_requirements_planning_report.material_requirements_planning_report.make_order",
|
||||
freeze: true,
|
||||
args: {
|
||||
selected_rows: selected_rows,
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
warehouse: !prompt_data.use_default_warehouse ? prompt_data.warehouse : null,
|
||||
mps: frappe.query_report.get_filter_value("mps"),
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frappe.set_route("List", r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
prompt_and_make_order(selected_rows);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function items_missing_bom(selected_rows) {
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const row of selected_rows) {
|
||||
if (row.type_of_material !== "Manufacture" || row.bom_no || seen.has(row.item_code)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(row.item_code);
|
||||
items.push({ item_code: row.item_code, item_name: row.item_name });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function apply_selected_boms(selected_rows, bom_rows) {
|
||||
const bom_by_item = {};
|
||||
for (const row of bom_rows || []) {
|
||||
if (row.item_code && row.bom_no) {
|
||||
bom_by_item[row.item_code] = row.bom_no;
|
||||
}
|
||||
}
|
||||
for (const row of selected_rows) {
|
||||
if (!row.bom_no && bom_by_item[row.item_code]) {
|
||||
row.bom_no = bom_by_item[row.item_code];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prompt_and_make_order(selected_rows) {
|
||||
const missing_bom = items_missing_bom(selected_rows);
|
||||
const fields = [
|
||||
{
|
||||
fieldname: "use_default_warehouse",
|
||||
label: __("Use Default Warehouse"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
label: __("Warehouse"),
|
||||
fieldtype: "Link",
|
||||
options: "Warehouse",
|
||||
depends_on: "eval:!doc.use_default_warehouse",
|
||||
mandatory_depends_on: "eval:!doc.use_default_warehouse",
|
||||
},
|
||||
];
|
||||
|
||||
if (missing_bom.length) {
|
||||
fields.push({
|
||||
label: __("Select BOM"),
|
||||
fieldtype: "Table",
|
||||
fieldname: "boms",
|
||||
reqd: 1,
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
in_place_edit: true,
|
||||
description: __("These items have no default BOM. Select one to create Work Orders."),
|
||||
data: missing_bom,
|
||||
get_data: () => missing_bom,
|
||||
fields: [
|
||||
{
|
||||
fieldtype: "Link",
|
||||
fieldname: "item_code",
|
||||
options: "Item",
|
||||
label: __("Item Code"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
},
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "item_name",
|
||||
label: __("Item Name"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
},
|
||||
{
|
||||
fieldtype: "Link",
|
||||
fieldname: "bom_no",
|
||||
options: "BOM",
|
||||
reqd: 1,
|
||||
label: __("BOM"),
|
||||
in_list_view: 1,
|
||||
get_query: (doc) => ({
|
||||
query: "erpnext.controllers.queries.bom",
|
||||
filters: { item: doc.item_code },
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Make Purchase / Work Order"),
|
||||
fields: fields,
|
||||
primary_action_label: __("Create"),
|
||||
primary_action(values) {
|
||||
if (missing_bom.length) {
|
||||
const bom_rows = dialog.fields_dict.boms.grid.get_data();
|
||||
const without_bom = bom_rows.filter((row) => !row.bom_no);
|
||||
if (without_bom.length) {
|
||||
frappe.msgprint(
|
||||
__("Please select a BOM for {0}", [
|
||||
without_bom.map((row) => row.item_code).join(", "),
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
apply_selected_boms(selected_rows, bom_rows);
|
||||
}
|
||||
|
||||
dialog.hide();
|
||||
frappe.call({
|
||||
method: "erpnext.manufacturing.report.material_requirements_planning_report.material_requirements_planning_report.make_order",
|
||||
freeze: true,
|
||||
args: {
|
||||
selected_rows: selected_rows,
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
warehouse: values.use_default_warehouse ? null : values.warehouse,
|
||||
mps: frappe.query_report.get_filter_value("mps"),
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frappe.set_route("List", r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
@@ -440,6 +441,7 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
row.indent = 0
|
||||
row.bom_no = rm_details.get("bom_no")
|
||||
row.lead_time = math.ceil(rm_details.get("lead_time", 0))
|
||||
if not row.sales_forecast_qty:
|
||||
row.sales_forecast_qty = 0
|
||||
|
||||
@@ -448,7 +450,7 @@ class MaterialRequirementsPlanningReport:
|
||||
if row.get("is_adhoc"):
|
||||
row.planned_qty += row.adhoc_qty
|
||||
|
||||
for field in ("min_order_qty", "purchase_uom", "safety_stock", "default_supplier"):
|
||||
for field in ["min_order_qty", "purchase_uom", "safety_stock"]:
|
||||
if rm_details.get(field):
|
||||
row[field] = rm_details.get(field)
|
||||
|
||||
@@ -458,11 +460,20 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
i += 1
|
||||
row.capacity = 0
|
||||
row.type_of_material = get_type_of_material(rm_details.get("is_purchase_item"), row.bom_no)
|
||||
if rm_details.raw_materials:
|
||||
row.capacity = get_item_capacity(row.item_code, self.filters.bucket_size)
|
||||
row.type_of_material = "Manufacture"
|
||||
if row.lead_time and row.required_qty:
|
||||
row.lead_time = math.ceil(row.required_qty / row.lead_time)
|
||||
elif not row.required_qty:
|
||||
row.lead_time = 0
|
||||
else:
|
||||
row.type_of_material = "Purchase"
|
||||
|
||||
self.set_lead_time(row, rm_details.raw_materials)
|
||||
if not row.lead_time and rm_details.raw_materials:
|
||||
row.lead_time = self.get_lead_time_from_raw_materials(rm_details.raw_materials)
|
||||
|
||||
row.release_date = add_days(row.delivery_date, row.lead_time * -1)
|
||||
data.append(row)
|
||||
if rm_details.raw_materials:
|
||||
self.update_rm_details(
|
||||
@@ -471,36 +482,12 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
return data
|
||||
|
||||
def set_lead_time(self, row, raw_materials=None):
|
||||
lead_time = get_item_lead_time(row.item_code, row.type_of_material, row.required_qty)
|
||||
if (
|
||||
raw_materials
|
||||
and row.required_qty > 0
|
||||
and flt(get_item_lead_time_details(row.item_code).manufacturing_time_in_mins) <= 0
|
||||
):
|
||||
lead_time += self.get_lead_time_from_raw_materials(raw_materials, row.required_qty)
|
||||
|
||||
row.lead_time = math.ceil(lead_time)
|
||||
row.release_date = add_days(row.delivery_date, -row.lead_time)
|
||||
|
||||
def get_lead_time_from_raw_materials(self, raw_materials, qty=1):
|
||||
def get_lead_time_from_raw_materials(self, raw_materials):
|
||||
lead_time = 0
|
||||
for material in raw_materials:
|
||||
material_qty = material.stock_qty * qty
|
||||
# Reuse descendant totals within this report, keeping different net quantities separate.
|
||||
subtree_lead_times = material.setdefault("subtree_lead_times", {})
|
||||
if material_qty not in subtree_lead_times:
|
||||
type_of_material = get_type_of_material(material.get("is_purchase_item"), material.bom_no)
|
||||
material_lead_time = math.ceil(
|
||||
get_item_lead_time(material.item_code, type_of_material, material_qty)
|
||||
)
|
||||
if material.raw_materials:
|
||||
material_lead_time += self.get_lead_time_from_raw_materials(
|
||||
material.raw_materials, material_qty
|
||||
)
|
||||
subtree_lead_times[material_qty] = material_lead_time
|
||||
|
||||
lead_time += subtree_lead_times[material_qty]
|
||||
lead_time += math.ceil(material.lead_time)
|
||||
if material.raw_materials:
|
||||
lead_time += self.get_lead_time_from_raw_materials(material.raw_materials)
|
||||
|
||||
return lead_time
|
||||
|
||||
@@ -796,6 +783,7 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
def update_rm_details(self, raw_materials, delivery_date, planned_qty, bom_no, data):
|
||||
for material in raw_materials:
|
||||
lead_time = math.ceil(material.lead_time)
|
||||
row = frappe._dict(
|
||||
{
|
||||
"item_code": material.item_code,
|
||||
@@ -805,6 +793,8 @@ class MaterialRequirementsPlanningReport:
|
||||
"planned_qty": material.stock_qty * planned_qty,
|
||||
"projected_qty": 0,
|
||||
"delivery_date": delivery_date,
|
||||
"lead_time": lead_time,
|
||||
"release_date": add_days(delivery_date, lead_time * -1),
|
||||
"indent": material.indent + 1,
|
||||
"parent_bom": bom_no,
|
||||
"bom_no": material.bom_no,
|
||||
@@ -816,12 +806,13 @@ class MaterialRequirementsPlanningReport:
|
||||
)
|
||||
|
||||
row.capacity = 0
|
||||
row.type_of_material = get_type_of_material(material.get("is_purchase_item"), material.bom_no)
|
||||
if material.raw_materials:
|
||||
row.capacity = get_item_capacity(material.item_code, self.filters.bucket_size)
|
||||
row.type_of_material = "Manufacture"
|
||||
else:
|
||||
row.type_of_material = "Purchase"
|
||||
|
||||
self.update_required_qty(row)
|
||||
self.set_lead_time(row, material.raw_materials)
|
||||
|
||||
data.append(row)
|
||||
|
||||
@@ -904,19 +895,14 @@ class MaterialRequirementsPlanningReport:
|
||||
item_wise_rm_details[item_code] = frappe.db.get_value(
|
||||
"Item",
|
||||
item_code,
|
||||
[
|
||||
"default_bom as bom_no",
|
||||
"safety_stock",
|
||||
"min_order_qty",
|
||||
"purchase_uom",
|
||||
"is_purchase_item",
|
||||
],
|
||||
["default_bom as bom_no", "safety_stock", "min_order_qty", "purchase_uom"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
item_data = item_wise_rm_details[item_code]
|
||||
if details := get_item_details(item_code, self.filters.get("company")):
|
||||
item_data.update(details)
|
||||
item_data.lead_time = get_item_lead_time(
|
||||
item_code, "Manufacture" if item_data.bom_no else "Purchase"
|
||||
)
|
||||
|
||||
if item_code not in self.fg_items:
|
||||
self.fg_items.append(item_code)
|
||||
@@ -954,6 +940,9 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
if material.bom_no:
|
||||
material.raw_materials = self.get_raw_materials(material.bom_no, indent + 1)
|
||||
material.lead_time = get_item_lead_time(material.item_code, "Manufacture")
|
||||
else:
|
||||
material.lead_time = get_item_lead_time(material.item_code, "Purchase")
|
||||
|
||||
return raw_materials
|
||||
|
||||
@@ -1112,7 +1101,7 @@ class MaterialRequirementsPlanningReport:
|
||||
from_date = get_first_day(from_date)
|
||||
|
||||
dates_list = []
|
||||
while getdate(from_date) <= getdate(self.filters.to_date):
|
||||
while getdate(self.filters.to_date) > getdate(from_date):
|
||||
args = {"from_date": from_date}
|
||||
|
||||
days = 1 if bucket_size == "Daily" else 7
|
||||
@@ -1201,17 +1190,10 @@ class MaterialRequirementsPlanningReport:
|
||||
return convert_to_daily_bucket_data(sales_data)
|
||||
|
||||
|
||||
def get_type_of_material(is_purchase_item, bom_no):
|
||||
return "Purchase" if is_purchase_item and not bom_no else "Manufacture"
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_item_details(item_code, company):
|
||||
data = frappe.db.get_value(
|
||||
"Item",
|
||||
item_code,
|
||||
["safety_stock", "min_order_qty", "purchase_uom", "is_purchase_item"],
|
||||
as_dict=True,
|
||||
"Item", item_code, ["safety_stock", "min_order_qty", "purchase_uom"], as_dict=True
|
||||
) or frappe._dict({"safety_stock": 0})
|
||||
|
||||
default_data = frappe.db.get_value(
|
||||
@@ -1227,33 +1209,32 @@ def get_item_details(item_code, company):
|
||||
return data
|
||||
|
||||
|
||||
def get_item_lead_time(item_code, type_of_material, qty=1):
|
||||
"""Return calendar days, scaling only manufacturing time by the required quantity."""
|
||||
details = get_item_lead_time_details(item_code)
|
||||
if type_of_material == "Manufacture":
|
||||
if qty <= 0:
|
||||
return 0
|
||||
# Keep MRP's 24-hour planning day; buffer days do not increase production capacity.
|
||||
time_in_days = max(flt(details.manufacturing_time_in_mins), 0) * qty / 1440.0
|
||||
else:
|
||||
if details.purchase_time is None:
|
||||
return 0
|
||||
time_in_days = flt(details.purchase_time)
|
||||
|
||||
return time_in_days + flt(details.buffer_time)
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_item_lead_time_details(item_code):
|
||||
return (
|
||||
frappe.db.get_value(
|
||||
"Item Lead Time",
|
||||
{"item_code": item_code},
|
||||
["manufacturing_time_in_mins", "purchase_time", "buffer_time"],
|
||||
as_dict=True,
|
||||
def get_item_lead_time(item_code, type_of_material):
|
||||
doctype = frappe.qb.DocType("Item Lead Time")
|
||||
|
||||
query = frappe.qb.from_(doctype).where(doctype.item_code == item_code)
|
||||
|
||||
if type_of_material == "Manufacture":
|
||||
query = query.select(
|
||||
Case()
|
||||
.when(
|
||||
(doctype.manufacturing_time_in_mins.isnull() | (doctype.manufacturing_time_in_mins <= 0)), 0
|
||||
)
|
||||
.else_(1440.0 / doctype.manufacturing_time_in_mins + doctype.buffer_time)
|
||||
.as_("lead_time")
|
||||
)
|
||||
or frappe._dict()
|
||||
)
|
||||
else:
|
||||
query = query.select(
|
||||
Case()
|
||||
.when(doctype.purchase_time.isnull(), 0)
|
||||
.else_(doctype.purchase_time + doctype.buffer_time)
|
||||
.as_("lead_time")
|
||||
)
|
||||
|
||||
time = query.run(pluck="lead_time")
|
||||
|
||||
return time[0] if time else 0
|
||||
|
||||
|
||||
def convert_to_daily_bucket_data(data):
|
||||
@@ -1332,7 +1313,6 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
|
||||
purchase_orders = {}
|
||||
work_orders = []
|
||||
covered_rows = 0
|
||||
missing_bom = []
|
||||
for row in selected_rows:
|
||||
row = frappe._dict(row)
|
||||
# what is left to order once stock and the orders already placed are counted. rounding
|
||||
@@ -1345,14 +1325,8 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
|
||||
if row.type_of_material == "Purchase":
|
||||
purchase_orders.setdefault((row.default_supplier, row.release_date), []).append(row)
|
||||
|
||||
if row.type_of_material == "Manufacture":
|
||||
if row.bom_no:
|
||||
work_orders.append(row)
|
||||
elif row.item_code not in missing_bom:
|
||||
missing_bom.append(row.item_code)
|
||||
|
||||
if missing_bom:
|
||||
frappe.throw(_("Default BOM for {0} not found").format(", ".join(missing_bom)))
|
||||
if row.type_of_material == "Manufacture" and row.bom_no:
|
||||
work_orders.append(row)
|
||||
|
||||
if not purchase_orders and not work_orders:
|
||||
frappe.msgprint(
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests.classes.context_managers import freeze_time
|
||||
from frappe.utils import add_days, flt, formatdate, getdate, today
|
||||
from frappe.utils import add_days, flt, formatdate, today
|
||||
|
||||
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
@@ -16,7 +14,6 @@ from erpnext.manufacturing.report.material_requirements_planning_report.material
|
||||
make_order,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
@@ -74,8 +71,10 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
|
||||
[formatdate(delivery_date, "dd MMM") for delivery_date in delivery_dates],
|
||||
)
|
||||
|
||||
def test_manufacture_lead_time_preserves_fractional_days(self):
|
||||
"""Manufacturing duration must retain fractional days until the report rounds it."""
|
||||
def test_manufacture_lead_time_is_not_int_truncated(self):
|
||||
"""lead_time = 1440 / manufacturing_time_in_mins + buffer_time. Both columns are Int;
|
||||
integer/integer division truncates on Postgres (1440/7 -> 205) while MariaDB yields a
|
||||
decimal, so the computed lead time (and the derived release date) diverged by engine."""
|
||||
item = make_item("_Test MRP Lead Time Item", {"is_stock_item": 1}).name
|
||||
frappe.get_doc(
|
||||
{
|
||||
@@ -87,222 +86,8 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
|
||||
).insert()
|
||||
|
||||
lead_time = get_item_lead_time(item, "Manufacture")
|
||||
self.assertAlmostEqual(lead_time, 7 / 1440 + 2, places=8)
|
||||
|
||||
@freeze_time("2026-09-01 10:00:00.123456")
|
||||
def test_manufacturing_buffer_moves_release_date_earlier(self):
|
||||
plan = make_mrp_plan(self, planned_qty=49, rm_qty=1)
|
||||
mps = frappe.get_doc("Master Production Schedule", plan.mps)
|
||||
mps.items[0].delivery_date = "2026-09-30"
|
||||
mps.save()
|
||||
lead_time = frappe.get_doc(
|
||||
{"doctype": "Item Lead Time", "item_code": plan.fg_item, "manufacturing_time_in_mins": 30}
|
||||
).insert()
|
||||
frappe.get_doc(
|
||||
{"doctype": "Item Lead Time", "item_code": plan.rm_item, "purchase_time": 3, "buffer_time": 1}
|
||||
).insert()
|
||||
|
||||
for buffer_days, expected_days in ((0, 2), (1, 3), (2, 4)):
|
||||
with self.subTest(buffer_days=buffer_days):
|
||||
lead_time.buffer_time = buffer_days
|
||||
lead_time.save()
|
||||
rows = get_mrp_rows(mps)
|
||||
fg_row, rm_row = rows[plan.fg_item], rows[plan.rm_item]
|
||||
self.assertEqual(fg_row.required_qty, 49)
|
||||
self.assertEqual(fg_row.lead_time, expected_days)
|
||||
self.assertEqual(
|
||||
getdate(fg_row.release_date), getdate(add_days("2026-09-30", -expected_days))
|
||||
)
|
||||
self.assertEqual(rm_row.lead_time, 4)
|
||||
self.assertEqual(rm_row.delivery_date, fg_row.release_date)
|
||||
|
||||
def test_manufacturing_duration_boundaries_and_missing_operation_time(self):
|
||||
plan = make_mrp_plan(self, planned_qty=49, rm_qty=1)
|
||||
mps = frappe.get_doc("Master Production Schedule", plan.mps)
|
||||
lead_time = frappe.get_doc({"doctype": "Item Lead Time", "item_code": plan.fg_item}).insert()
|
||||
frappe.get_doc({"doctype": "Item Lead Time", "item_code": plan.rm_item, "purchase_time": 3}).insert()
|
||||
|
||||
cases = (
|
||||
(30, 48, 0, 1),
|
||||
(30, 49, 0, 2),
|
||||
(30, 96, 0, 2),
|
||||
(31, 47, 0, 2),
|
||||
(3000, 1, 0, 3),
|
||||
(30, 0.5, 1, 2),
|
||||
(30, 0, 1, 0),
|
||||
(0, 49, 1, 4),
|
||||
(-30, 49, 1, 4),
|
||||
)
|
||||
for minutes, qty, buffer_days, expected_days in cases:
|
||||
with self.subTest(minutes=minutes, qty=qty, buffer_days=buffer_days):
|
||||
mps.items[0].planned_qty = qty
|
||||
mps.save()
|
||||
lead_time.update({"manufacturing_time_in_mins": minutes, "buffer_time": buffer_days})
|
||||
lead_time.save()
|
||||
row = get_mrp_rows(mps)[plan.fg_item]
|
||||
self.assertEqual(row.lead_time, expected_days)
|
||||
self.assertEqual(row.release_date, add_days(row.delivery_date, -expected_days))
|
||||
|
||||
def test_subassembly_buffer_uses_net_required_quantity(self):
|
||||
plan = make_mrp_plan(self, planned_qty=49, rm_qty=1)
|
||||
parent_item = make_item(properties={"is_stock_item": 1}).name
|
||||
parent_bom = make_bom(item=parent_item, raw_materials=[plan.fg_item], rm_qty=2, rate=100)
|
||||
self.assertEqual(parent_bom.items[0].bom_no, plan.bom)
|
||||
mps = frappe.get_doc("Master Production Schedule", plan.mps)
|
||||
mps.items[0].item_code = parent_item
|
||||
mps.save()
|
||||
for item in (parent_item, plan.fg_item):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Lead Time",
|
||||
"item_code": item,
|
||||
"manufacturing_time_in_mins": 30,
|
||||
"buffer_time": 1,
|
||||
}
|
||||
).insert()
|
||||
frappe.get_doc({"doctype": "Item Lead Time", "item_code": plan.rm_item, "purchase_time": 3}).insert()
|
||||
|
||||
rows = get_mrp_rows(mps)
|
||||
self.assertEqual(rows[parent_item].lead_time, 3)
|
||||
self.assertEqual(rows[plan.fg_item].required_qty, 98)
|
||||
self.assertEqual(rows[plan.fg_item].lead_time, 4)
|
||||
self.assertEqual(rows[plan.fg_item].delivery_date, rows[parent_item].release_date)
|
||||
self.assertEqual(rows[plan.rm_item].delivery_date, rows[plan.fg_item].release_date)
|
||||
|
||||
make_stock_entry(item_code=plan.fg_item, target=WAREHOUSE, qty=50, rate=100)
|
||||
rows = get_mrp_rows(mps)
|
||||
self.assertEqual(rows[plan.fg_item].required_qty, 48)
|
||||
self.assertEqual(rows[plan.fg_item].lead_time, 2)
|
||||
self.assertEqual(rows[plan.rm_item].required_qty, 48)
|
||||
self.assertEqual(rows[plan.rm_item].lead_time, 3)
|
||||
|
||||
def test_raw_material_fallback_reuses_subtrees_by_required_quantity(self):
|
||||
plan = make_mrp_plan(self, planned_qty=49, rm_qty=1)
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Lead Time",
|
||||
"item_code": plan.fg_item,
|
||||
"manufacturing_time_in_mins": 30,
|
||||
"buffer_time": 1,
|
||||
}
|
||||
).insert()
|
||||
frappe.get_doc({"doctype": "Item Lead Time", "item_code": plan.rm_item, "purchase_time": 3}).insert()
|
||||
|
||||
parents = []
|
||||
child_item = plan.fg_item
|
||||
for _ in range(6):
|
||||
parent_item = make_item(properties={"is_stock_item": 1}).name
|
||||
make_bom(item=parent_item, raw_materials=[child_item], rm_qty=1, rate=100)
|
||||
frappe.get_doc({"doctype": "Item Lead Time", "item_code": parent_item, "buffer_time": 1}).insert()
|
||||
parents.append(parent_item)
|
||||
child_item = parent_item
|
||||
|
||||
mps = frappe.get_doc("Master Production Schedule", plan.mps)
|
||||
mps.items[0].item_code = parents[-1]
|
||||
mps.save()
|
||||
with patch(f"{execute.__module__}.get_item_lead_time", wraps=get_item_lead_time) as lead_time:
|
||||
rows = get_mrp_rows(mps)
|
||||
# Descendant calculations should grow with the row count, not the square of BOM depth.
|
||||
self.assertLessEqual(lead_time.call_count, 2 * len(rows))
|
||||
|
||||
for level, parent_item in enumerate(parents):
|
||||
self.assertEqual(rows[parent_item].required_qty, 49)
|
||||
self.assertEqual(rows[parent_item].lead_time, level + 7)
|
||||
self.assertEqual(rows[plan.fg_item].lead_time, 3)
|
||||
self.assertEqual(rows[plan.rm_item].lead_time, 3)
|
||||
|
||||
# Stock changes the quantity below this assembly after the ancestor fallback was calculated.
|
||||
make_stock_entry(item_code=parents[2], target=WAREHOUSE, qty=1, rate=100)
|
||||
rows = get_mrp_rows(mps)
|
||||
self.assertEqual(rows[parents[-1]].lead_time, 12)
|
||||
self.assertEqual(rows[parents[2]].required_qty, 48)
|
||||
self.assertEqual(rows[parents[2]].lead_time, 8)
|
||||
self.assertEqual(rows[plan.fg_item].required_qty, 48)
|
||||
self.assertEqual(rows[plan.fg_item].lead_time, 2)
|
||||
|
||||
def test_manufactured_component_without_bom_keeps_buffer_duration(self):
|
||||
plan = make_mrp_plan(self, planned_qty=49, rm_qty=1)
|
||||
item = frappe.get_doc("Item", plan.rm_item)
|
||||
item.is_purchase_item = 0
|
||||
item.save()
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Lead Time",
|
||||
"item_code": plan.rm_item,
|
||||
"manufacturing_time_in_mins": 30,
|
||||
"buffer_time": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
rows = get_mrp_rows(frappe.get_doc("Master Production Schedule", plan.mps))
|
||||
component = rows[plan.rm_item]
|
||||
self.assertEqual(component.type_of_material, "Manufacture")
|
||||
self.assertFalse(component.bom_no)
|
||||
self.assertEqual(component.lead_time, 3)
|
||||
self.assertEqual(rows[plan.fg_item].lead_time, 3)
|
||||
self.assertEqual(component.delivery_date, rows[plan.fg_item].release_date)
|
||||
|
||||
def test_purchase_item_without_bom_is_purchased(self):
|
||||
plan = make_mps_item(
|
||||
self,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 1,
|
||||
"item_defaults": [
|
||||
{"company": COMPANY, "default_warehouse": WAREHOUSE, "default_supplier": SUPPLIER}
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(plan.row.type_of_material, "Purchase")
|
||||
|
||||
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
purchase_order = get_created_order(plan.mps, "Purchase Order")
|
||||
self.assertEqual([d.item_code for d in purchase_order.items], [plan.item])
|
||||
self.assertFalse(frappe.get_all("Work Order", filters={"mps": plan.mps}, pluck="name"))
|
||||
|
||||
def test_make_order_rejects_manufactured_item_without_bom(self):
|
||||
plan = make_mps_item(
|
||||
self,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 0,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(plan.row.type_of_material, "Manufacture")
|
||||
self.assertFalse(plan.row.bom_no)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError) as ctx:
|
||||
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
self.assertIn("Default BOM", str(ctx.exception))
|
||||
self.assertFalse(frappe.get_all("Work Order", filters={"mps": plan.mps}, pluck="name"))
|
||||
self.assertFalse(frappe.get_all("Purchase Order", filters={"mps": plan.mps}, pluck="name"))
|
||||
|
||||
def test_make_order_uses_the_bom_passed_on_the_row(self):
|
||||
plan = make_mps_item(
|
||||
self,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 0,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
},
|
||||
)
|
||||
rm_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 1,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
}
|
||||
).name
|
||||
plan.row.bom_no = make_bom(item=plan.item, raw_materials=[rm_item], rm_qty=1, rate=100).name
|
||||
|
||||
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
work_order = get_created_order(plan.mps, "Work Order")
|
||||
self.assertEqual(work_order.production_item, plan.item)
|
||||
self.assertEqual(work_order.bom_no, plan.row.bom_no)
|
||||
# 1440 / 7 + 2 = 207.714...; a truncating integer division on Postgres would give 207.
|
||||
self.assertAlmostEqual(float(lead_time), 1440 / 7 + 2, places=2)
|
||||
|
||||
def test_make_order_creates_draft_purchase_and_work_orders(self):
|
||||
plan = make_mrp_plan(self)
|
||||
@@ -403,32 +188,6 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
|
||||
purchase_order.grand_total, net_total + net_total * flt(template.taxes[0].rate) / 100
|
||||
)
|
||||
|
||||
def test_buckets_include_the_period_that_ends_on_a_bucket_boundary(self):
|
||||
"""A to_date landing on a bucket's first day must still get that bucket's column."""
|
||||
cases = [
|
||||
("Monthly", "2026-11-01", "2026-12-01", ["2026-11-01", "2026-12-01"]),
|
||||
("Monthly", "2026-12-01", "2026-12-01", ["2026-12-01"]),
|
||||
("Daily", "2026-11-30", "2026-12-01", ["2026-11-30", "2026-12-01"]),
|
||||
("Weekly", "2026-11-30", "2026-12-07", ["2026-11-30", "2026-12-07"]),
|
||||
]
|
||||
|
||||
for bucket_size, from_date, to_date, bucket_starts in cases:
|
||||
with self.subTest(bucket_size=bucket_size, to_date=to_date):
|
||||
report = MaterialRequirementsPlanningReport(
|
||||
frappe._dict({"bucket_size": bucket_size, "from_date": from_date, "to_date": to_date})
|
||||
)
|
||||
|
||||
dates = report.get_dates()
|
||||
self.assertEqual(
|
||||
[getdate(d["from_date"]) for d in dates],
|
||||
[getdate(start) for start in bucket_starts],
|
||||
)
|
||||
if bucket_size == "Monthly":
|
||||
self.assertEqual(
|
||||
[d["label"] for d in dates],
|
||||
[formatdate(start, "MMM YYYY") for start in bucket_starts],
|
||||
)
|
||||
|
||||
|
||||
def make_chart_row(delivery_date, planned_qty=1):
|
||||
return frappe._dict(
|
||||
@@ -442,47 +201,6 @@ def make_chart_row(delivery_date, planned_qty=1):
|
||||
)
|
||||
|
||||
|
||||
def make_mps_item(test_case, item_properties, planned_qty=10):
|
||||
item = make_item(properties=item_properties).name
|
||||
mps = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Master Production Schedule",
|
||||
"company": COMPANY,
|
||||
"posting_date": today(),
|
||||
"from_date": today(),
|
||||
"parent_warehouse": WAREHOUSE,
|
||||
"items": [
|
||||
{
|
||||
"item_code": item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"delivery_date": add_days(today(), 30),
|
||||
"planned_qty": planned_qty,
|
||||
"uom": frappe.get_cached_value("Item", item, "stock_uom"),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
mps.insert()
|
||||
|
||||
_, data, _, _ = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": COMPANY,
|
||||
"from_date": today(),
|
||||
"to_date": add_days(today(), 90),
|
||||
"warehouse": WAREHOUSE,
|
||||
"mps": mps.name,
|
||||
"type_of_material": "All",
|
||||
"add_safety_stock": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
rows = [row for row in data if row.get("item_code")]
|
||||
test_case.assertTrue(rows, msg="the report returned no rows to create orders from")
|
||||
|
||||
return frappe._dict(item=item, mps=mps.name, row=rows[0], rows=rows)
|
||||
|
||||
|
||||
def make_mrp_plan(test_case, planned_qty=10, rm_qty=2):
|
||||
"""Build a finished good with a submitted BOM and an MPS demanding it, then return the
|
||||
report's own output rows -- the same payload the report's client sends to `make_order`."""
|
||||
@@ -555,26 +273,6 @@ def make_mrp_plan(test_case, planned_qty=10, rm_qty=2):
|
||||
)
|
||||
|
||||
|
||||
def get_mrp_rows(mps):
|
||||
# Changing settings and refreshing the report happens in separate requests in Desk.
|
||||
frappe.local.request_cache.clear()
|
||||
_, rows, _, _ = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": mps.company,
|
||||
"warehouse": mps.parent_warehouse,
|
||||
"mps": mps.name,
|
||||
"from_date": mps.from_date,
|
||||
"to_date": mps.to_date,
|
||||
"type_of_material": "All",
|
||||
"bucket_size": "Daily",
|
||||
"add_safety_stock": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
return {row.item_code: row for row in rows if row.get("item_code")}
|
||||
|
||||
|
||||
def get_ordered_items(doctype, order):
|
||||
child_doctype = "Purchase Order Item" if doctype == "Purchase Order" else None
|
||||
if not child_doctype:
|
||||
|
||||
@@ -1,137 +1,19 @@
|
||||
import time
|
||||
|
||||
import frappe
|
||||
|
||||
CHILD_TABLE = "tabSerial and Batch Entry"
|
||||
|
||||
BUNDLE_TABLE = "tabSerial and Batch Bundle"
|
||||
|
||||
CHUNK_SIZE = 50_000
|
||||
|
||||
# Denormalised columns copied from the bundle onto every entry row.
|
||||
COLUMNS = (
|
||||
"posting_datetime",
|
||||
"voucher_type",
|
||||
"voucher_no",
|
||||
"voucher_detail_no",
|
||||
"type_of_transaction",
|
||||
"is_cancelled",
|
||||
"item_code",
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
if not frappe.db.has_table("Serial and Batch Entry"):
|
||||
return
|
||||
|
||||
# Only ever used to give the log a denominator. It is a cached information_schema
|
||||
# estimate, so it can read 0 for a table that has rows -- gating the backfill on it
|
||||
# would silently skip the whole migration. The loop below decides when it is done.
|
||||
total = frappe.db.estimate_count("Serial and Batch Entry")
|
||||
|
||||
last_name = ""
|
||||
done = 0
|
||||
started_at = time.monotonic()
|
||||
|
||||
while True:
|
||||
upper = get_chunk_end(last_name)
|
||||
|
||||
update_chunk(last_name, upper)
|
||||
|
||||
# Commit per chunk. Doing every row in one transaction grows the undo log until
|
||||
# each read has to walk it, which is what made this run for hours on large sites.
|
||||
frappe.db.commit()
|
||||
|
||||
if not upper:
|
||||
# The tail is whatever was left after the last boundary, so it has to be
|
||||
# counted rather than assumed. Only ever scans a sub-chunk range.
|
||||
done += frappe.db.count("Serial and Batch Entry", {"name": (">", last_name)})
|
||||
log_progress(done, total, started_at)
|
||||
break
|
||||
|
||||
# A bounded chunk is exactly CHUNK_SIZE rows by construction.
|
||||
done += CHUNK_SIZE
|
||||
last_name = upper
|
||||
log_progress(done, total, started_at)
|
||||
|
||||
|
||||
def get_chunk_end(last_name):
|
||||
"""Return the name that closes the next chunk, or None when the tail is left.
|
||||
|
||||
Keyset pagination, so each chunk is a sequential range scan on the clustered
|
||||
index rather than a deep OFFSET over the whole table.
|
||||
"""
|
||||
entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
|
||||
boundary = (
|
||||
frappe.qb.from_(entry)
|
||||
.select(entry.name)
|
||||
.where(entry.name > last_name)
|
||||
.orderby(entry.name)
|
||||
.limit(1)
|
||||
.offset(CHUNK_SIZE - 1)
|
||||
).run(pluck=True)
|
||||
|
||||
return boundary[0] if boundary else None
|
||||
|
||||
|
||||
def update_chunk(last_name, upper):
|
||||
"""Copy the bundle's values onto one chunk of entries.
|
||||
|
||||
Raw SQL because the query builder cannot express this statement. Every one of
|
||||
COLUMNS exists on both tables, and pypika renders the assignment target without
|
||||
its table (`_set_sql` forces `with_namespace=False`), so a joined UPDATE fails
|
||||
with "Column 'voucher_no' in field list is ambiguous". Aliasing the bundle in a
|
||||
derived table clears the ambiguity but makes MariaDB materialise the whole bundle
|
||||
table for every chunk and drive the join from it, and a correlated subquery per
|
||||
column costs one lookup per column per row instead of one per row.
|
||||
|
||||
The two dialects spell a joined UPDATE differently and Frappe does not translate
|
||||
between them, so each gets its own statement.
|
||||
"""
|
||||
condition = "AND SABE.name <= %(upper)s" if upper else ""
|
||||
|
||||
frappe.db.multisql(
|
||||
{
|
||||
"mariadb": get_mariadb_query(condition),
|
||||
"postgres": get_postgres_query(condition),
|
||||
},
|
||||
{"last_name": last_name, "upper": upper},
|
||||
)
|
||||
|
||||
|
||||
def get_mariadb_query(condition):
|
||||
set_clause = ",\n\t\t\t\t".join(f"SABE.{column} = SABB.{column}" for column in COLUMNS)
|
||||
|
||||
return f"""
|
||||
UPDATE `{CHILD_TABLE}` SABE
|
||||
INNER JOIN `{BUNDLE_TABLE}` SABB
|
||||
ON SABE.parent = SABB.name
|
||||
SET
|
||||
{set_clause}
|
||||
WHERE SABE.name > %(last_name)s {condition}
|
||||
"""
|
||||
|
||||
|
||||
def get_postgres_query(condition):
|
||||
# Postgres joins through FROM rather than JOIN, and rejects the table alias on the
|
||||
# assignment target, so the SET columns are bare here.
|
||||
set_clause = ",\n\t\t\t\t".join(f"{column} = SABB.{column}" for column in COLUMNS)
|
||||
|
||||
return f"""
|
||||
UPDATE `{CHILD_TABLE}` SABE
|
||||
SET
|
||||
{set_clause}
|
||||
FROM `{BUNDLE_TABLE}` SABB
|
||||
WHERE SABE.parent = SABB.name
|
||||
AND SABE.name > %(last_name)s {condition}
|
||||
"""
|
||||
|
||||
|
||||
def log_progress(done, total, started_at):
|
||||
elapsed = time.monotonic() - started_at
|
||||
rate = done / elapsed if elapsed else 0
|
||||
print(
|
||||
f"Serial and Batch Entry: {done:,} rows of ~{total:,} ({rate:,.0f} rows/sec)",
|
||||
flush=True,
|
||||
)
|
||||
if frappe.db.has_table("Serial and Batch Entry"):
|
||||
frappe.db.sql(
|
||||
"""
|
||||
UPDATE `tabSerial and Batch Entry` SABE, `tabSerial and Batch Bundle` SABB
|
||||
SET
|
||||
SABE.posting_datetime = SABB.posting_datetime,
|
||||
SABE.voucher_type = SABB.voucher_type,
|
||||
SABE.voucher_no = SABB.voucher_no,
|
||||
SABE.voucher_detail_no = SABB.voucher_detail_no,
|
||||
SABE.type_of_transaction = SABB.type_of_transaction,
|
||||
SABE.is_cancelled = SABB.is_cancelled,
|
||||
SABE.item_code = SABB.item_code
|
||||
WHERE SABE.parent = SABB.name
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -34,8 +34,7 @@ def update_itemised_tax_data(doc):
|
||||
def export_invoices(filters: str | None = None):
|
||||
frappe.has_permission("Sales Invoice", throw=True)
|
||||
|
||||
# get_list, not get_all: what leaves here is a zip of e-invoice attachments, so the rows must be scoped too
|
||||
invoices = frappe.get_list(
|
||||
invoices = frappe.get_all(
|
||||
"Sales Invoice", filters=get_conditions(filters), fields=["name", "company_tax_id"]
|
||||
)
|
||||
|
||||
|
||||
@@ -843,16 +843,6 @@ def get_credit_limit(customer, company):
|
||||
def get_customer_primary(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
customer = filters.get("customer")
|
||||
type = filters.get("type")
|
||||
|
||||
# `type` is caller-supplied and was interpolated straight into qb.DocType(), so any doctype on
|
||||
# the site could be joined to Dynamic Link and read. The two pickers that call this
|
||||
# (customer.js:84,94) send only these two values.
|
||||
if type not in ("Contact", "Address"):
|
||||
frappe.throw(_("Invalid type"), frappe.PermissionError)
|
||||
|
||||
# authorise the party, not Contact/Address: the `if_owner` row on Address would empty the picker rather than error
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
type_doctype = qb.DocType(type)
|
||||
dlink = qb.DocType("Dynamic Link")
|
||||
|
||||
|
||||
@@ -233,14 +233,16 @@ def get_new_item_code(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
searchfield = searchfield.split(",")
|
||||
searchfield.append("name")
|
||||
|
||||
# get_list applies Item's permission conditions and User Permissions, as item_query() does
|
||||
return frappe.get_list(
|
||||
"Item",
|
||||
filters=[["is_stock_item", "=", 0], ["is_fixed_asset", "=", 0]],
|
||||
or_filters=[[fieldname, "like", f"%{txt}%"] for fieldname in searchfield] if searchfield else None,
|
||||
fields=["name", "item_name"],
|
||||
order_by="", # the query this replaced had no ORDER BY; suppress the injected default
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
item = frappe.qb.DocType("Item")
|
||||
query = (
|
||||
frappe.qb.from_(item)
|
||||
.select(item.name, item.item_name)
|
||||
.where((item.is_stock_item == 0) & (item.is_fixed_asset == 0))
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
|
||||
if searchfield:
|
||||
query = query.where(Criterion.any([item[fieldname].like(f"%{txt}%") for fieldname in searchfield]))
|
||||
|
||||
return query.run()
|
||||
|
||||
@@ -98,9 +98,7 @@ class ProformaInvoice(Document):
|
||||
@frappe.whitelist()
|
||||
def get_sales_order_items(sales_order: str) -> list[dict]:
|
||||
"""Sales Order lines (with already-proformed totals) to drive the create-proforma dialog."""
|
||||
# this returns line rates and amounts for a caller-named order, so the order itself is what
|
||||
# decides access. check_permission applies User Permissions, which a doctype check would not.
|
||||
sales_order_doc = frappe.get_doc("Sales Order", sales_order, check_permission="read")
|
||||
sales_order_doc = frappe.get_doc("Sales Order", sales_order)
|
||||
proformed = get_proformed_totals(sales_order)
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1104,8 +1104,7 @@ def create_pick_list(source_name: str, target_doc: str | dict | Document | None
|
||||
|
||||
doc.purpose = "Delivery"
|
||||
|
||||
if not doc.pick_manually:
|
||||
doc.set_item_locations()
|
||||
doc.set_item_locations()
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
@@ -790,19 +790,14 @@ def is_enable_cutoff_date_on_bulk_delivery_note_creation():
|
||||
return frappe.get_single_value("Selling Settings", "enable_cutoff_date_on_bulk_delivery_note_creation")
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def close_or_unclose_sales_orders(names: str | list, status: str):
|
||||
frappe.has_permission("Sales Order", "write", throw=True)
|
||||
if not frappe.has_permission("Sales Order", "write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
names = frappe.parse_json(names)
|
||||
for name in names:
|
||||
if not isinstance(name, str):
|
||||
frappe.throw(_("Invalid name"), frappe.PermissionError)
|
||||
|
||||
# the check above is doctype level and never consults User Permissions, so on its own it
|
||||
# lets a caller restricted to one company close another company's orders. Checking each
|
||||
# document is what scopes it, and matches what update_status() below already does.
|
||||
so = frappe.get_lazy_doc("Sales Order", name, check_permission="submit")
|
||||
so = frappe.get_lazy_doc("Sales Order", name)
|
||||
if so.docstatus == 1:
|
||||
if status == "Closed":
|
||||
if so.status not in ("Cancelled", "Closed") and (
|
||||
@@ -862,7 +857,7 @@ def get_events(start: str, end: str, filters: str | dict | None = None):
|
||||
return data
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def update_status(status: str, name: str):
|
||||
so = frappe.get_doc("Sales Order", name, check_permission="submit")
|
||||
so.update_status(status)
|
||||
|
||||
@@ -85,7 +85,7 @@ class SalesOrderStockReservation:
|
||||
create_stock_reservation_entries_for_so_items as create_stock_reservation_entries,
|
||||
)
|
||||
|
||||
packed_items = self._extract_packed_item_details(items_details, from_voucher_type)
|
||||
packed_items = self._extract_packed_item_details(items_details)
|
||||
|
||||
sre_count = 0
|
||||
if items_details != []:
|
||||
@@ -100,28 +100,17 @@ class SalesOrderStockReservation:
|
||||
if items:
|
||||
self._reserve_packed_items(items, sre_count, notify)
|
||||
|
||||
def _extract_packed_item_details(
|
||||
self, items_details: list[dict] | None, from_voucher_type: str | None = None
|
||||
) -> list:
|
||||
"""Pull packed-item rows (whose Sales Order Item no longer exists) out of items_details
|
||||
and rewrite them into the payload StockReservation reads."""
|
||||
if not items_details:
|
||||
return []
|
||||
def _extract_packed_item_details(self, items_details: list[dict] | None) -> list:
|
||||
"""Pull packed-item rows (whose Sales Order Item no longer exists) out of items_details."""
|
||||
packed_items = []
|
||||
if items_details:
|
||||
for item in items_details:
|
||||
if not frappe.db.exists("Sales Order Item", item.get("sales_order_item")):
|
||||
item["qty"] = item.pop("qty_to_reserve")
|
||||
packed_items.append(item)
|
||||
|
||||
packed_items = [
|
||||
item
|
||||
for item in items_details
|
||||
if not frappe.db.exists("Sales Order Item", item.get("sales_order_item"))
|
||||
]
|
||||
|
||||
for item in packed_items:
|
||||
items_details.remove(item)
|
||||
item["qty"] = item.pop("qty_to_reserve")
|
||||
item["from_voucher_type"] = from_voucher_type
|
||||
|
||||
picked_bundle = item.pop("serial_and_batch_bundle", None)
|
||||
if picked_bundle:
|
||||
item["serial_and_batch_bundles"] = [picked_bundle]
|
||||
for item in packed_items:
|
||||
items_details.remove(item)
|
||||
|
||||
return packed_items
|
||||
|
||||
|
||||
@@ -1970,33 +1970,6 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
self.assertEqual(so.packed_items[0].ordered_qty, 2)
|
||||
self.assertEqual(so.packed_items[1].ordered_qty, 2)
|
||||
|
||||
def test_ordered_qty_is_reset_when_cancelled_sales_order_is_unlinked(self):
|
||||
"""Cancelling a Sales Order unlinks its Purchase Orders, so `ordered_qty` must be recomputed."""
|
||||
selected_items = [{"item_code": "_Test Item", "supplier": "_Test Supplier"}]
|
||||
so = make_sales_order(item_code="_Test Item", qty=10)
|
||||
|
||||
purchase_order = make_purchase_order(so.name, selected_items=selected_items)[0]
|
||||
purchase_order.schedule_date = add_days(nowdate(), 1)
|
||||
purchase_order.submit()
|
||||
|
||||
so.reload()
|
||||
self.assertEqual(so.items[0].ordered_qty, 10)
|
||||
|
||||
so.cancel()
|
||||
so.reload()
|
||||
self.assertEqual(so.items[0].ordered_qty, 0)
|
||||
|
||||
amended_so = frappe.copy_doc(so)
|
||||
amended_so.amended_from = so.name
|
||||
amended_so.docstatus = 0
|
||||
amended_so.insert()
|
||||
amended_so.submit()
|
||||
|
||||
self.assertEqual(amended_so.items[0].ordered_qty, 0)
|
||||
|
||||
new_purchase_order = make_purchase_order(amended_so.name, selected_items=selected_items)[0]
|
||||
self.assertEqual(new_purchase_order.items[0].qty, 10)
|
||||
|
||||
def test_reserved_qty_for_closing_so(self):
|
||||
bin = frappe.get_all(
|
||||
"Bin",
|
||||
|
||||
@@ -121,20 +121,8 @@ def filter_result_items(result, pos_profile):
|
||||
result["items"] = [item for item in result.get("items") if item.get("item_group") in pos_item_groups]
|
||||
|
||||
|
||||
def check_pos_profile_access(pos_profile: str | None) -> None:
|
||||
"""The POS Profile is what entitles a caller to POS data — see the Bin/Item analysis on
|
||||
pos_invoice.get_stock_availability. Record-level when a profile is named, so a Company User
|
||||
Permission applies too."""
|
||||
if isinstance(pos_profile, str) and pos_profile:
|
||||
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
|
||||
else:
|
||||
frappe.has_permission("POS Profile", throw=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_parent_item_group(pos_profile: str):
|
||||
check_pos_profile_access(pos_profile)
|
||||
|
||||
item_groups = get_item_groups(pos_profile)
|
||||
|
||||
if not item_groups:
|
||||
@@ -152,8 +140,6 @@ def get_items(
|
||||
pos_profile: str,
|
||||
search_term: str = "",
|
||||
):
|
||||
check_pos_profile_access(pos_profile)
|
||||
|
||||
warehouse, hide_unavailable_items = frappe.db.get_value(
|
||||
"POS Profile", pos_profile, ["warehouse", "hide_unavailable_items"]
|
||||
)
|
||||
@@ -286,9 +272,6 @@ def get_items(
|
||||
|
||||
@frappe.whitelist()
|
||||
def search_for_serial_or_batch_or_barcode_number(search_value: str) -> dict[str, str | None]:
|
||||
# POS-page wrapper around scan_barcode; the page's entitlement is the POS Profile.
|
||||
frappe.has_permission("POS Profile", throw=True)
|
||||
|
||||
return scan_barcode(search_value)
|
||||
|
||||
|
||||
@@ -333,7 +316,6 @@ def get_item_group_condition(pos_profile, item=None):
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def item_group_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
pos_profile = filters.get("pos_profile")
|
||||
check_pos_profile_access(pos_profile)
|
||||
|
||||
item_filters = [["name", "like", f"%{txt}%"]]
|
||||
if pos_profile:
|
||||
@@ -341,8 +323,7 @@ def item_group_query(doctype: str, txt: str, searchfield: str, start: int, page_
|
||||
if item_groups:
|
||||
item_filters.append(["name", "in", item_groups])
|
||||
|
||||
# get_list, not get_all: it adds the caller's Item Group User Permissions; a Desk User select row keeps everyone in
|
||||
return frappe.get_list(
|
||||
return frappe.get_all(
|
||||
"Item Group",
|
||||
filters=item_filters,
|
||||
fields=["name"],
|
||||
@@ -356,10 +337,6 @@ def item_group_query(doctype: str, txt: str, searchfield: str, start: int, page_
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_opening_entry(user: str):
|
||||
# `user` was caller input, so anyone could enumerate another's open POS sessions; this is a POS Opening Entry question
|
||||
if user != frappe.session.user:
|
||||
frappe.has_permission("POS Opening Entry", throw=True)
|
||||
|
||||
open_vouchers = frappe.db.get_all(
|
||||
"POS Opening Entry",
|
||||
filters={"user": user, "pos_closing_entry": ["in", ["", None]], "docstatus": 1},
|
||||
@@ -372,10 +349,6 @@ def check_opening_entry(user: str):
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_opening_voucher(pos_profile: str, company: str, balance_details: str | list):
|
||||
# submit() enforces POS Opening Entry rights per document, but only after the profile and company
|
||||
# have been accepted from the caller — check the profile the session is being opened against.
|
||||
check_pos_profile_access(pos_profile)
|
||||
|
||||
balance_details = frappe.parse_json(balance_details)
|
||||
|
||||
new_pos_opening = frappe.get_doc(
|
||||
@@ -548,8 +521,6 @@ def set_customer_info(fieldname: str, customer: str, value: str = ""):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_pos_profile_data(pos_profile: str):
|
||||
check_pos_profile_access(pos_profile)
|
||||
|
||||
pos_profile = frappe.get_doc("POS Profile", pos_profile)
|
||||
pos_profile = pos_profile.as_dict()
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -119,54 +119,6 @@ class TestQuotationTrends(ERPNextTestSuite):
|
||||
self.assertEqual(len(lead_rows), 1)
|
||||
self.assertEqual(lead_rows[0][name_idx], lead.company_name or lead_name)
|
||||
|
||||
def test_prospect_quotation_reports_its_master_territory(self):
|
||||
"""Prospect stores a territory, so its quotations must report it, not a blank cell.
|
||||
|
||||
The CASE resolved territory for Customer and Lead only, so a Prospect quotation fell through
|
||||
to NULL even though the master carries the field.
|
||||
"""
|
||||
territory = "_Test Trends Prospect Territory"
|
||||
if not frappe.db.exists("Territory", territory):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Territory",
|
||||
"territory_name": territory,
|
||||
"parent_territory": "All Territories",
|
||||
"is_group": 0,
|
||||
}
|
||||
).insert()
|
||||
|
||||
prospect_name = "_Test Trends Prospect Party"
|
||||
if not frappe.db.exists("Prospect", prospect_name):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Prospect",
|
||||
"company_name": prospect_name,
|
||||
"company": "_Test Company",
|
||||
"territory": territory,
|
||||
}
|
||||
).insert()
|
||||
|
||||
quotation = frappe.new_doc("Quotation")
|
||||
quotation.company = "_Test Company"
|
||||
quotation.transaction_date = TXN_DATE
|
||||
quotation.currency = "INR"
|
||||
quotation.quotation_to = "Prospect"
|
||||
quotation.party_name = prospect_name
|
||||
quotation.append(
|
||||
"items",
|
||||
{"item_code": "_Test Item", "qty": 1, "rate": 100, "warehouse": "_Test Warehouse - _TC"},
|
||||
)
|
||||
quotation.insert()
|
||||
quotation.submit()
|
||||
|
||||
labels, rows = self.run_report(based_on="Customer")
|
||||
party_idx, territory_idx = labels.index("Party"), labels.index("Territory")
|
||||
prospect_rows = [row for row in rows if row[party_idx] == prospect_name]
|
||||
|
||||
self.assertEqual(len(prospect_rows), 1)
|
||||
self.assertEqual(prospect_rows[0][territory_idx], territory)
|
||||
|
||||
def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self):
|
||||
# _Test Item is quoted to two customers -> two detail rows under one header row.
|
||||
# _Test Item 2 is quoted to only one customer -> exactly one detail row under its
|
||||
|
||||
@@ -1082,10 +1082,7 @@ def get_children(doctype: str, parent: str | None = None, company: str | None =
|
||||
|
||||
filters = {"parent_company": parent} if parent else {"parent_company": ["is", "not set"]}
|
||||
|
||||
# get_list, not get_all: it applies the caller's Company permission and their Company User
|
||||
# Permissions, so a restricted user sees only their own companies. Matches the sibling tree
|
||||
# source in accounts/utils.py, which already uses get_list.
|
||||
return frappe.get_list(
|
||||
return frappe.get_all(
|
||||
"Company",
|
||||
filters=filters,
|
||||
fields=["name as value", "is_group as expandable"],
|
||||
@@ -1099,11 +1096,6 @@ def add_node():
|
||||
args = frappe.form_dict
|
||||
args = make_tree_args(**args)
|
||||
|
||||
# This is the Company tree's "add node" action; `args` comes straight from form_dict, so without
|
||||
# this the caller chooses the doctype that gets created. insert() would still check permissions
|
||||
# on whatever they named, but nothing else here is meant to build anything but a Company.
|
||||
args.doctype = "Company"
|
||||
|
||||
if args.parent_company == "All Companies":
|
||||
args.parent_company = None
|
||||
|
||||
@@ -1173,22 +1165,6 @@ def get_default_company_address(
|
||||
sort_key: Literal["is_shipping_address", "is_primary_address"] = "is_primary_address",
|
||||
existing_address: str | None = None,
|
||||
):
|
||||
# `Literal` is NOT enforced by typing_validations — measured, sort_key="name" was accepted — and
|
||||
# addr[sort_key] is a column reference, so check it here.
|
||||
if sort_key not in ("is_shipping_address", "is_primary_address"):
|
||||
frappe.throw(_("Invalid sort key"), frappe.PermissionError)
|
||||
|
||||
# Same boundary as accounts/custom/address.py::get_shipping_address: `select` denies the portal
|
||||
# identities and costs none of the twelve transaction-writing roles, and the company scoping is
|
||||
# what actually closes the cross-company read.
|
||||
frappe.has_permission("Company", ptype="select", throw=True)
|
||||
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Address")
|
||||
if allowed_companies and name not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(name), frappe.PermissionError)
|
||||
|
||||
addr = frappe.qb.DocType("Address")
|
||||
dl = frappe.qb.DocType("Dynamic Link")
|
||||
out = (
|
||||
|
||||
@@ -89,15 +89,10 @@ def get_children(
|
||||
else:
|
||||
filters["parent_department"] = parent
|
||||
|
||||
# `doctype` is caller-supplied and only ever reaches has_column here; the query below is fixed to
|
||||
# Department, so pin it rather than letting a caller probe another table's columns.
|
||||
if frappe.db.has_column("Department", "disabled") and not include_disabled:
|
||||
if frappe.db.has_column(doctype, "disabled") and not include_disabled:
|
||||
filters["disabled"] = False
|
||||
|
||||
# get_list, not get_all: it applies the caller's Department permission and their User
|
||||
# Permissions. Department carries no `if_owner` row, so this does not silently empty the tree —
|
||||
# the same check made before swapping the call in setup/doctype/company/company.py.
|
||||
return frappe.get_list("Department", fields=fields, filters=filters, order_by="name")
|
||||
return frappe.get_all("Department", fields=fields, filters=filters, order_by="name")
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@@ -107,11 +102,6 @@ def add_node():
|
||||
args = frappe.form_dict
|
||||
args = make_tree_args(**args)
|
||||
|
||||
# `args` comes straight from form_dict, so without this the caller chooses the doctype that gets
|
||||
# created. insert() would still check permissions on whatever they named, but the Department
|
||||
# tree's add-node action is not meant to build anything else.
|
||||
args.doctype = "Department"
|
||||
|
||||
if args.parent_department == args.company:
|
||||
args.parent_department = None
|
||||
|
||||
|
||||
@@ -76,38 +76,7 @@ frappe.ui.form.on("Item Group", {
|
||||
};
|
||||
};
|
||||
|
||||
frm.set_query("default_warehouse", "item_group_defaults", (doc, cdt, cdn) => {
|
||||
const row = locals[cdt][cdn];
|
||||
return {
|
||||
filters: { company: row.company, is_group: 0 },
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("default_inventory_account", "item_group_defaults", (doc, cdt, cdn) => {
|
||||
const row = locals[cdt][cdn];
|
||||
return {
|
||||
filters: { company: row.company, account_type: "Stock", is_group: 0 },
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("default_provisional_account", "item_group_defaults", (doc, cdt, cdn) => {
|
||||
const row = locals[cdt][cdn];
|
||||
return {
|
||||
filters: {
|
||||
company: row.company,
|
||||
root_type: ["in", ["Liability", "Asset"]],
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
[
|
||||
"purchase_expense_account",
|
||||
"purchase_expense_contra_account",
|
||||
"default_cogs_account",
|
||||
"expenses_added_to_stock_account",
|
||||
"expenses_added_to_stock_contra_account",
|
||||
].forEach((field) => {
|
||||
["expenses_added_to_stock_account", "expenses_added_to_stock_contra_account"].forEach((field) => {
|
||||
frm.fields_dict["item_group_defaults"].grid.get_field(field).get_query = function (
|
||||
doc,
|
||||
cdt,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user