mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-24 05:47:15 +00:00
Compare commits
108 Commits
v16.35.0
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e851108f1 | ||
|
|
e55efadd0d | ||
|
|
2387c67952 | ||
|
|
0c02cbaf1e | ||
|
|
ad8f945cce | ||
|
|
d0290ea98e | ||
|
|
61f66ed579 | ||
|
|
1e16c827ef | ||
|
|
be283b70f2 | ||
|
|
79858d5af6 | ||
|
|
48635ee6b5 | ||
|
|
8732c4eee3 | ||
|
|
0e1657a35a | ||
|
|
c019bb5c28 | ||
|
|
1ead4cebcf | ||
|
|
242f4219cb | ||
|
|
5d495c2763 | ||
|
|
868de6e513 | ||
|
|
54c094ed46 | ||
|
|
d4105c3aaf | ||
|
|
900eb1e466 | ||
|
|
cfa5c25215 | ||
|
|
04be8b1218 | ||
|
|
98fa0d4cb4 | ||
|
|
385343a389 | ||
|
|
4186d6c994 | ||
|
|
06d55a089a | ||
|
|
cdbdcc6e33 | ||
|
|
3ab13e12d4 | ||
|
|
0db0cc55d4 | ||
|
|
6af589208b | ||
|
|
06cc2914ba | ||
|
|
8e9c0f6e41 | ||
|
|
8eb906366d | ||
|
|
06a8faa4ed | ||
|
|
ff0841e0cb | ||
|
|
0fe41ce92e | ||
|
|
aaaba7cd82 | ||
|
|
814d7f0c4e | ||
|
|
0f1cefa918 | ||
|
|
6d89487518 | ||
|
|
fe8199bece | ||
|
|
a436707efc | ||
|
|
ae96dad151 | ||
|
|
d4b1be1154 | ||
|
|
38bdb4f9c0 | ||
|
|
33e86edc91 | ||
|
|
427a20c508 | ||
|
|
854b0e4734 | ||
|
|
9b8c35f431 | ||
|
|
7ff2a33a5b | ||
|
|
3ee9353240 | ||
|
|
4455ffcae1 | ||
|
|
ff29e14353 | ||
|
|
4af1b0db4e | ||
|
|
75c1a7fd69 | ||
|
|
d217283229 | ||
|
|
8bbb73d2e2 | ||
|
|
fa6b3380b9 | ||
|
|
2db2244852 | ||
|
|
f5eb2d4a0b | ||
|
|
6661c30db4 | ||
|
|
fa998703bf | ||
|
|
423aa09f59 | ||
|
|
fd369a5a0f | ||
|
|
af334dabcc | ||
|
|
0fbea6021b | ||
|
|
236464e6cc | ||
|
|
58847de59a | ||
|
|
eebd0bc533 | ||
|
|
c055faabc1 | ||
|
|
62f560a91d | ||
|
|
5e1cfa4f52 | ||
|
|
a28767599f | ||
|
|
f61caa5211 | ||
|
|
dbb8981fa1 | ||
|
|
816880b010 | ||
|
|
590fff6a45 | ||
|
|
0634cf31c7 | ||
|
|
a878951c17 | ||
|
|
56db17919b | ||
|
|
11cbcbe02a | ||
|
|
5d4271b1c7 | ||
|
|
cbd932e20d | ||
|
|
4ad08db1d6 | ||
|
|
131343de28 | ||
|
|
16b1821f35 | ||
|
|
d2042bc1bb | ||
|
|
652fc96e85 | ||
|
|
6fab4e0203 | ||
|
|
39d6f2a236 | ||
|
|
4f984aeecf | ||
|
|
18ee3b9cc0 | ||
|
|
3eca07169e | ||
|
|
3acf4e7606 | ||
|
|
007a1f5528 | ||
|
|
c42df7ef3e | ||
|
|
31111a4a30 | ||
|
|
877747c133 | ||
|
|
86fe0c1f4b | ||
|
|
262fdf3e69 | ||
|
|
e3ef3be490 | ||
|
|
f09ce0583f | ||
|
|
fa732ca3f4 | ||
|
|
741ae5d33c | ||
|
|
378c756d52 | ||
|
|
3c9d9d62aa | ||
|
|
110192d9de |
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.35.0"
|
||||
__version__ = "16.26.2"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -55,6 +55,9 @@ class ERPNextAddress(Address):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_shipping_address(company, address=None):
|
||||
# `select`, not `read`: Delivery, Maintenance, Purchase and Stock Manager fill in forms that ask
|
||||
# for this while holding no Company `read` row. doc= so User Permissions apply to the named company.
|
||||
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
|
||||
filters = [
|
||||
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||
["Dynamic Link", "link_name", "=", company],
|
||||
|
||||
@@ -24,7 +24,7 @@ def get(
|
||||
heatmap_year=None,
|
||||
):
|
||||
if chart_name:
|
||||
chart = frappe.get_doc("Dashboard Chart", chart_name)
|
||||
chart = frappe.get_doc("Dashboard Chart", chart_name, check_permission="read")
|
||||
else:
|
||||
chart = frappe._dict(frappe.parse_json(chart))
|
||||
timespan = chart.timespan
|
||||
@@ -53,6 +53,9 @@ def get(
|
||||
)
|
||||
)
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 18:14:42.202065",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Account",
|
||||
@@ -264,6 +264,46 @@
|
||||
{
|
||||
"role": "HR Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Quality Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -503,12 +503,19 @@ class Account(NestedSet):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_parent_account(doctype, txt, searchfield, start, page_len, filters):
|
||||
return frappe.db.sql(
|
||||
"""select name from tabAccount
|
||||
where is_group = 1 and docstatus != 2 and company = {}
|
||||
and {} like {} order by name limit {} offset {}""".format("%s", searchfield, "%s", "%s", "%s"),
|
||||
(filters["company"], "%%%s%%" % txt, page_len, start),
|
||||
as_list=1,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ def identify_is_group(child):
|
||||
def get_chart(chart_template, existing_company=None):
|
||||
chart = {}
|
||||
if existing_company:
|
||||
frappe.has_permission("Company", doc=existing_company, throw=True)
|
||||
|
||||
return get_account_tree_from_existing_company(existing_company)
|
||||
|
||||
elif chart_template == "Standard":
|
||||
|
||||
@@ -235,8 +235,11 @@ def delete_accounting_dimension(doc):
|
||||
frappe.clear_cache(doctype=doctype)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def disable_dimension(doc):
|
||||
# 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:
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:36.896195",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank",
|
||||
@@ -118,6 +118,14 @@
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Accounts User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
"link_fieldname": "default_bank_account"
|
||||
}
|
||||
],
|
||||
"modified": "2026-04-11 19:46:27.609994",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Account",
|
||||
@@ -299,6 +299,22 @@
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -60,6 +60,9 @@ 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":
|
||||
|
||||
@@ -68,6 +68,7 @@ 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: {
|
||||
@@ -82,7 +83,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
});
|
||||
|
||||
frm.add_custom_button(__("Get Unreconciled Entries"), function () {
|
||||
frm.trigger("make_reconciliation_tool");
|
||||
return frm.trigger("make_reconciliation_tool");
|
||||
});
|
||||
frm.change_custom_button_type(__("Get Unreconciled Entries"), null, "primary");
|
||||
|
||||
@@ -106,7 +107,24 @@ 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(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, create_batch, flt
|
||||
from frappe.utils import cint, create_batch, flt, getdate
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
|
||||
@@ -55,6 +55,8 @@ 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])
|
||||
@@ -794,6 +796,7 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
"deposit",
|
||||
"withdrawal",
|
||||
"bank_account",
|
||||
"company",
|
||||
"currency",
|
||||
"unallocated_amount",
|
||||
"date",
|
||||
@@ -828,11 +831,7 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
"paid_from": paid_from,
|
||||
"paid_to": paid_to,
|
||||
"paid_amount": bank_transaction.unallocated_amount,
|
||||
"base_paid_amount": bank_transaction.unallocated_amount,
|
||||
"received_amount": bank_transaction.unallocated_amount,
|
||||
"base_received_amount": bank_transaction.unallocated_amount,
|
||||
"target_exchange_rate": 1,
|
||||
"source_exchange_rate": 1,
|
||||
"reference_date": bank_transaction.date,
|
||||
"posting_date": bank_transaction.date,
|
||||
"reference_no": (bank_transaction.reference_number or bank_transaction.description or "")[
|
||||
@@ -841,6 +840,8 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
}
|
||||
)
|
||||
|
||||
set_multi_currency_amounts(payment_entry_doc)
|
||||
|
||||
payment_entry_doc.insert()
|
||||
payment_entry_doc.submit()
|
||||
|
||||
@@ -879,6 +880,7 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
|
||||
"doctype": "Payment Entry",
|
||||
}
|
||||
)
|
||||
set_multi_currency_amounts(payment_entry)
|
||||
payment_entry.insert()
|
||||
payment_entry.submit()
|
||||
transaction = reconcile_vouchers(
|
||||
@@ -901,6 +903,33 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
|
||||
}
|
||||
|
||||
|
||||
def set_multi_currency_amounts(pe):
|
||||
"""Set real exchange rates when the bank and party accounts differ in currency."""
|
||||
company_currency = frappe.get_cached_value("Company", pe.company, "default_currency")
|
||||
pe.paid_from_account_currency = frappe.get_cached_value("Account", pe.paid_from, "account_currency")
|
||||
pe.paid_to_account_currency = frappe.get_cached_value("Account", pe.paid_to, "account_currency")
|
||||
|
||||
pe.source_exchange_rate = (
|
||||
1.0
|
||||
if pe.paid_from_account_currency == company_currency
|
||||
else get_exchange_rate(pe.paid_from_account_currency, company_currency, pe.posting_date)
|
||||
)
|
||||
pe.target_exchange_rate = (
|
||||
1.0
|
||||
if pe.paid_to_account_currency == company_currency
|
||||
else get_exchange_rate(pe.paid_to_account_currency, company_currency, pe.posting_date)
|
||||
)
|
||||
|
||||
# derive the party-side amount from the authoritative bank-side amount; Payment Entry books any
|
||||
# rounding residual to Exchange Gain/Loss during validation (set_exchange_gain_loss)
|
||||
if pe.payment_type == "Receive" and pe.source_exchange_rate:
|
||||
base_amount = flt(pe.received_amount) * pe.target_exchange_rate
|
||||
pe.paid_amount = flt(base_amount / pe.source_exchange_rate, pe.precision("paid_amount"))
|
||||
elif pe.payment_type == "Pay" and pe.target_exchange_rate:
|
||||
base_amount = flt(pe.paid_amount) * pe.source_exchange_rate
|
||||
pe.received_amount = flt(base_amount / pe.target_exchange_rate, pe.precision("received_amount"))
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["GET"])
|
||||
def search_for_transfer_transaction(transaction_id: str | int):
|
||||
"""
|
||||
@@ -969,6 +998,7 @@ def auto_reconcile_vouchers(
|
||||
from_reference_date: str | date | None = None,
|
||||
to_reference_date: str | date | 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:
|
||||
@@ -1087,6 +1117,7 @@ def get_linked_payments(
|
||||
to_reference_date: str | date | 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,6 +1137,23 @@ 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 = []
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
# See license.txt
|
||||
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
|
||||
auto_reconcile_vouchers,
|
||||
create_bulk_payment_entry_and_reconcile,
|
||||
create_payment_entry_and_reconcile,
|
||||
get_auto_reconcile_message,
|
||||
get_bank_transactions,
|
||||
get_linked_payments,
|
||||
@@ -16,6 +20,8 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
RATE_METHOD = "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_exchange_rate"
|
||||
|
||||
|
||||
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
@@ -131,6 +137,33 @@ 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_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(
|
||||
@@ -199,3 +232,117 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertIn("1 Transaction Partially Reconciled", singular)
|
||||
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
|
||||
self.assertIn("2 Transactions Partially Reconciled", plural)
|
||||
|
||||
def test_multi_currency_pay_converts_and_balances(self):
|
||||
# withdrawal from an INR bank paying a USD supplier; rate 3.0 makes 100/3 non-exact
|
||||
self.enable_multi_currency_setup()
|
||||
pe = self.reconcile_new_payment(
|
||||
self.make_multi_currency_txn(withdrawal=100),
|
||||
payment_type="Pay",
|
||||
party_type="Supplier",
|
||||
party=self.supplier,
|
||||
party_account=self.creditors_usd,
|
||||
paid_from=self.bank,
|
||||
paid_to=self.creditors_usd,
|
||||
rate=3.0,
|
||||
)
|
||||
self.assertEqual(pe.docstatus, 1) # submits despite the rounding residual
|
||||
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (1.0, 3.0))
|
||||
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33)) # bank side kept, 100/3
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
# Payment Entry auto-books the rounding residual to Exchange Gain/Loss
|
||||
self.assertTrue(pe.deductions[0].is_exchange_gain_loss)
|
||||
self.assertEqual(pe.deductions[0].amount, 0.01) # 100 - 33.33 * 3
|
||||
|
||||
def test_multi_currency_receive_converts_and_balances(self):
|
||||
# deposit into an INR bank from a USD customer; the party side must convert
|
||||
self.enable_multi_currency_setup()
|
||||
pe = self.reconcile_new_payment(
|
||||
self.make_multi_currency_txn(deposit=100),
|
||||
payment_type="Receive",
|
||||
party_type="Customer",
|
||||
party=self.customer,
|
||||
party_account=self.debtors_usd,
|
||||
paid_from=self.debtors_usd,
|
||||
paid_to=self.bank,
|
||||
rate=3.0,
|
||||
)
|
||||
self.assertEqual(pe.docstatus, 1)
|
||||
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (3.0, 1.0))
|
||||
self.assertEqual((pe.received_amount, pe.paid_amount), (100, 33.33)) # bank side kept, 100/3
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
|
||||
def test_multi_currency_bulk_pay_converts_and_balances(self):
|
||||
# the bulk path builds the Payment Entry itself, so it must convert too
|
||||
self.enable_multi_currency_setup()
|
||||
txn = self.make_multi_currency_txn(withdrawal=100)
|
||||
with patch(RATE_METHOD, return_value=3.0):
|
||||
result = create_bulk_payment_entry_and_reconcile(
|
||||
[txn.name], "Supplier", self.supplier, self.creditors_usd
|
||||
)
|
||||
|
||||
pe = frappe.get_doc("Payment Entry", result[0]["payment_entry"].name)
|
||||
self.assertEqual(pe.docstatus, 1)
|
||||
self.assertEqual(pe.target_exchange_rate, 3.0)
|
||||
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33))
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
|
||||
def enable_multi_currency_setup(self):
|
||||
# USD party/accounts + a company gain/loss account to absorb rounding residuals
|
||||
self.company_abbr = "_TC"
|
||||
self.create_supplier(supplier_name="_Test Supplier USD", currency="USD")
|
||||
self.create_customer(customer_name="_Test Customer USD", currency="USD")
|
||||
self.create_usd_payable_account()
|
||||
self.create_usd_receivable_account()
|
||||
self.set_party_account("Supplier", self.supplier, self.creditors_usd)
|
||||
if not frappe.db.get_value("Company", self.company, "exchange_gain_loss_account"):
|
||||
frappe.db.set_value(
|
||||
"Company", self.company, "exchange_gain_loss_account", "Exchange Gain/Loss - _TC"
|
||||
)
|
||||
|
||||
def set_party_account(self, party_type, party, account):
|
||||
doc = frappe.get_doc(party_type, party)
|
||||
if not any(row.company == self.company for row in doc.accounts):
|
||||
doc.append("accounts", {"company": self.company, "account": account})
|
||||
doc.save()
|
||||
|
||||
def make_multi_currency_txn(self, withdrawal=0, deposit=0):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Transaction",
|
||||
"date": today(),
|
||||
"withdrawal": withdrawal,
|
||||
"deposit": deposit,
|
||||
"bank_account": self.bank_account,
|
||||
"currency": "INR",
|
||||
"reference_number": "TEST-FX-REF",
|
||||
}
|
||||
)
|
||||
.save()
|
||||
.submit()
|
||||
)
|
||||
|
||||
def reconcile_new_payment(
|
||||
self, txn, *, payment_type, party_type, party, party_account, paid_from, paid_to, rate
|
||||
):
|
||||
# mimics the /banking frontend, which sends a hardcoded 1:1 rate
|
||||
payment_entry_doc = {
|
||||
"payment_type": payment_type,
|
||||
"company": self.company,
|
||||
"party_type": party_type,
|
||||
"party": party,
|
||||
"party_account": party_account,
|
||||
"paid_from": paid_from,
|
||||
"paid_to": paid_to,
|
||||
"paid_amount": txn.unallocated_amount,
|
||||
"received_amount": txn.unallocated_amount,
|
||||
"source_exchange_rate": 1,
|
||||
"target_exchange_rate": 1,
|
||||
"posting_date": today(),
|
||||
"reference_no": f"TEST-FX-{payment_type}",
|
||||
"reference_date": today(),
|
||||
}
|
||||
with patch(RATE_METHOD, return_value=rate):
|
||||
result = create_payment_entry_and_reconcile(txn.name, payment_entry_doc)
|
||||
return frappe.get_doc("Payment Entry", result["payment_entry"].name)
|
||||
|
||||
@@ -436,6 +436,9 @@ def get_import_logs(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_bank_statement(**args):
|
||||
# The only caller routes into a new Bank Statement Import form, so `create` is the right to require.
|
||||
frappe.has_permission("Bank Statement Import", "create", throw=True)
|
||||
|
||||
args = frappe._dict(args)
|
||||
bsi = frappe.new_doc("Bank Statement Import")
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ 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. Both functions here
|
||||
# are reached only over HTTP, so there is no in-process 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()
|
||||
@@ -34,8 +38,13 @@ def upload_bank_statement():
|
||||
return {"columns": columns, "data": data}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_bank_entries(columns, data, bank_account):
|
||||
# insert()/submit() enforce this per document, but only after the loop has read each Bank Account
|
||||
# and written an Error Log per rejected row. Check once up front.
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ from functools import reduce
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.file.utils import find_file_by_url
|
||||
from frappe.desk.form.linked_with import get_linked_fields
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, cstr
|
||||
@@ -58,6 +59,8 @@ def validate_columns(data):
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_company(company):
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
|
||||
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
|
||||
)
|
||||
@@ -110,7 +113,10 @@ def import_coa(file_name, company):
|
||||
|
||||
|
||||
def get_file(file_name):
|
||||
file_doc = frappe.get_doc("File", {"file_url": file_name})
|
||||
file_doc = find_file_by_url(file_name)
|
||||
if not file_doc:
|
||||
raise frappe.PermissionError
|
||||
|
||||
parts = file_doc.get_extension()
|
||||
extension = parts[1]
|
||||
extension = extension.lstrip(".")
|
||||
@@ -177,6 +183,8 @@ def generate_data_from_excel(file_doc, extension, as_dict=False):
|
||||
def get_coa(doctype, parent, is_root=False, file_name=None, for_validate=0):
|
||||
"""called by tree view (to fetch node's children)"""
|
||||
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
file_doc, extension = get_file(file_name)
|
||||
parent = None if parent == _("All Accounts") else parent
|
||||
|
||||
@@ -324,6 +332,8 @@ def build_response_as_excel(writer):
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_template(file_type, template_type, company):
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
writer = get_template(template_type, company)
|
||||
|
||||
if file_type == "CSV":
|
||||
@@ -376,7 +386,6 @@ def get_sample_template(writer, company):
|
||||
return writer
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_accounts(file_doc, extension):
|
||||
if extension == "csv":
|
||||
accounts = generate_data_from_csv(file_doc, as_dict=True)
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 18:15:27.367298",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cost Center",
|
||||
@@ -181,6 +181,54 @@
|
||||
{
|
||||
"role": "HR Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Projects Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Projects User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Quality Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -5,7 +5,10 @@ frappe.ui.form.on("Coupon Code", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("pricing_rule", function () {
|
||||
return {
|
||||
filters: [["Pricing Rule", "coupon_code_based", "=", "1"]],
|
||||
filters: {
|
||||
coupon_code_based: 1,
|
||||
disable: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-11-19 16:35:11.836441",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Coupon Code",
|
||||
@@ -179,6 +179,14 @@
|
||||
"role": "Website Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
|
||||
@@ -42,7 +42,23 @@ class CouponCode(Document):
|
||||
self.coupon_code = frappe.generate_hash()[:10].upper()
|
||||
|
||||
def validate(self):
|
||||
self.validate_from_to_dates("valid_from", "valid_upto")
|
||||
self.validate_pricing_rule()
|
||||
|
||||
if self.coupon_type == "Gift Card":
|
||||
self.maximum_use = 1
|
||||
if not self.customer:
|
||||
frappe.throw(_("Please select the customer."))
|
||||
|
||||
def validate_pricing_rule(self):
|
||||
if not self.pricing_rule or self.from_external_ecomm_platform:
|
||||
return
|
||||
|
||||
# Allow existing coupons to be updated after their pricing rule is disabled.
|
||||
if not (
|
||||
self.has_value_changed("pricing_rule") or self.has_value_changed("from_external_ecomm_platform")
|
||||
):
|
||||
return
|
||||
|
||||
if frappe.db.get_value("Pricing Rule", self.pricing_rule, "disable"):
|
||||
frappe.throw(_("Pricing Rule {0} is disabled").format(frappe.bold(self.pricing_rule)))
|
||||
|
||||
@@ -113,6 +113,43 @@ class TestCouponCode(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
test_create_test_data()
|
||||
|
||||
def test_disabled_pricing_rule_validation(self):
|
||||
coupon = frappe.get_doc("Coupon Code", "SAVE30")
|
||||
rule = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
|
||||
rule.disable = 1
|
||||
rule.save()
|
||||
|
||||
with self.subTest("new coupon cannot select a disabled rule"):
|
||||
new_coupon = frappe.copy_doc(coupon)
|
||||
new_coupon.coupon_name = "Festival Savings"
|
||||
new_coupon.coupon_code = "FESTSAVE"
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
new_coupon.insert()
|
||||
|
||||
with self.subTest("existing coupon can retain a disabled rule"):
|
||||
coupon.description = "Offer paused"
|
||||
coupon.save()
|
||||
coupon.reload()
|
||||
self.assertEqual(coupon.description, "Offer paused")
|
||||
self.assertEqual(coupon.pricing_rule, rule.name)
|
||||
|
||||
with self.subTest("existing coupon cannot switch to a disabled rule"):
|
||||
disabled_rule = frappe.copy_doc(rule)
|
||||
disabled_rule.insert()
|
||||
coupon.reload()
|
||||
coupon.pricing_rule = disabled_rule.name
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
coupon.save()
|
||||
coupon.reload()
|
||||
self.assertEqual(coupon.pricing_rule, rule.name)
|
||||
|
||||
def test_cannot_save_coupon_with_reversed_validity_dates(self):
|
||||
coupon = frappe.get_doc("Coupon Code", "SAVE30")
|
||||
coupon.valid_from = "2026-09-17"
|
||||
coupon.valid_upto = "2026-09-02"
|
||||
with self.assertRaises(frappe.exceptions.InvalidDates):
|
||||
coupon.save()
|
||||
|
||||
def test_sales_order_with_coupon_code(self):
|
||||
frappe.db.set_value("Coupon Code", "SAVE30", "used", 0)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
],
|
||||
"icon": "fa fa-book",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:44.514241",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Finance Book",
|
||||
@@ -55,6 +55,18 @@
|
||||
"report": 1,
|
||||
"role": "Auditor",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"role": "HR Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Quality Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"icon": "fa fa-calendar",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-27 17:29:55.560840",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Fiscal Year",
|
||||
@@ -131,6 +131,10 @@
|
||||
{
|
||||
"read": 1,
|
||||
"role": "Auditor"
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"show_name_in_global_search": 1,
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:55.573483",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Item Tax Template",
|
||||
@@ -95,6 +95,54 @@
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Item Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"show_name_in_global_search": 1,
|
||||
|
||||
@@ -1356,6 +1356,10 @@ def get_default_bank_cash_account(
|
||||
):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
|
||||
|
||||
# `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. doc= brings User Permissions to bear.
|
||||
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")
|
||||
|
||||
@@ -1384,6 +1388,10 @@ def get_default_bank_cash_account(
|
||||
account = account_list[0].name
|
||||
|
||||
if account:
|
||||
# `fetch_balance` is caller supplied, so authorise the account here rather than relying on
|
||||
# get_balance_on(), which only checks on the branch that reads a balance.
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
account_details = frappe.get_cached_value(
|
||||
"Account", account, ["account_currency", "account_type"], as_dict=1
|
||||
)
|
||||
@@ -1568,30 +1576,39 @@ def get_against_jv(doctype, txt, searchfield, start, page_len, filters):
|
||||
if not frappe.db.has_column("Journal Entry", searchfield):
|
||||
return []
|
||||
|
||||
JournalEntry = frappe.qb.DocType("Journal Entry")
|
||||
JournalEntryAccount = frappe.qb.DocType("Journal Entry Account")
|
||||
account = filters.get("account")
|
||||
party = filters.get("party")
|
||||
|
||||
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)
|
||||
# each names one value: a list would be read as a filter operator and widen the search.
|
||||
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"]
|
||||
)
|
||||
|
||||
party = filters.get("party")
|
||||
if party:
|
||||
query = query.where(JournalEntryAccount.party == party)
|
||||
else:
|
||||
query = query.where(JournalEntryAccount.party.isnull() | (JournalEntryAccount.party == ""))
|
||||
|
||||
return query.run()
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1810,6 +1827,10 @@ def make_reverse_journal_entry(source_name, target_doc=None):
|
||||
|
||||
def post_process(source, target):
|
||||
target.reversal_of = source.name
|
||||
target.naming_series = source.naming_series
|
||||
if source.voucher_type == "Bank Entry":
|
||||
target.cheque_no = source.cheque_no
|
||||
target.cheque_date = source.cheque_date
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:03.361383",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Loyalty Program",
|
||||
@@ -171,6 +171,14 @@
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -96,6 +96,8 @@ def get_loyalty_program_details_with_points(
|
||||
include_expired_entry=False,
|
||||
current_transaction_amount=0,
|
||||
):
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
lp_details = get_loyalty_program_details(customer, loyalty_program, company=company, silent=silent)
|
||||
loyalty_program = frappe.get_doc("Loyalty Program", loyalty_program)
|
||||
loyalty_details = get_loyalty_details(
|
||||
@@ -126,6 +128,10 @@ def get_loyalty_program_details(
|
||||
silent=False,
|
||||
include_expired_entry=False,
|
||||
):
|
||||
# The customer is what the caller is entitled to, not the programme: a check on Loyalty Program
|
||||
# would be read-only to System Manager and deny every role that fills in the calling forms.
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
lp_details = frappe._dict()
|
||||
|
||||
if not loyalty_program:
|
||||
@@ -147,6 +153,13 @@ def get_loyalty_program_details(
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_redeemption_factor(loyalty_program=None, customer=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")
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 18:16:47.795986",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Mode of Payment",
|
||||
@@ -76,6 +76,30 @@
|
||||
{
|
||||
"role": "HR Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"icon": "fa fa-bar-chart",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:05.873547",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Monthly Distribution",
|
||||
@@ -69,6 +69,10 @@
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
|
||||
@@ -57,26 +57,56 @@ class PaymentOrder(Document):
|
||||
frappe.db.set_value(self.payment_order_type, d.get(ref_doc_field), ref_field, status)
|
||||
|
||||
|
||||
def _readable_payment_order(filters):
|
||||
"""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, txt, searchfield, start, page_len, filters):
|
||||
return frappe.db.sql(
|
||||
""" select mode_of_payment from `tabPayment Order Reference`
|
||||
where parent = %(parent)s and mode_of_payment like %(txt)s
|
||||
limit %(page_len)s offset %(start)s""",
|
||||
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
|
||||
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}%"]},
|
||||
fields=["mode_of_payment"],
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_supplier_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
return frappe.db.sql(
|
||||
""" select supplier from `tabPayment Order Reference`
|
||||
where parent = %(parent)s and supplier like %(txt)s and
|
||||
(payment_reference is null or payment_reference='')
|
||||
limit %(page_len)s offset %(start)s""",
|
||||
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
|
||||
if not _readable_payment_order(filters):
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Payment Order Reference",
|
||||
filters={
|
||||
"parent": filters.get("parent"),
|
||||
"supplier": ["like", f"%{txt}%"],
|
||||
"payment_reference": ["is", "not set"],
|
||||
},
|
||||
fields=["supplier"],
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:11.511137",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Term",
|
||||
@@ -157,6 +157,30 @@
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -254,6 +254,13 @@ class POSClosingEntry(StatusUpdater):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
|
||||
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]
|
||||
|
||||
|
||||
@@ -1637,7 +1637,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
@@ -1680,6 +1680,14 @@
|
||||
"permlevel": 1,
|
||||
"read": 1,
|
||||
"role": "All"
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -899,6 +899,19 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_availability(item_code, warehouse):
|
||||
# select-or-read on POS Profile: a bare check defaults to `read`, which Sales Manager lacks.
|
||||
# The profile is the only boundary that fits -- Item and Bin read both exclude Accounts Manager.
|
||||
ptype = "select" if frappe.only_has_select_perm("POS Profile") else "read"
|
||||
frappe.has_permission("POS Profile", ptype, throw=True)
|
||||
|
||||
# the caller picks the warehouse when allow_warehouse_change is set; 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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import map_child_doc, map_doc
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
|
||||
from frappe.utils.background_jobs import enqueue, is_job_enqueued
|
||||
from frappe.utils.scheduler import is_scheduler_inactive
|
||||
@@ -16,7 +17,6 @@ from frappe.utils.scheduler import is_scheduler_inactive
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_checks_for_pl_and_bs_accounts,
|
||||
)
|
||||
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
|
||||
|
||||
|
||||
class POSInvoiceMergeLog(Document):
|
||||
@@ -214,6 +214,8 @@ class POSInvoiceMergeLog(Document):
|
||||
|
||||
loyalty_amount_sum, loyalty_points_sum, idx = 0, 0, 1
|
||||
|
||||
reversed_rows = get_reversed_rows([doc.return_against for doc in data if doc.is_return])
|
||||
|
||||
for doc in data:
|
||||
old_new_item_map = frappe._dict()
|
||||
old_new_tax_map = frappe._dict()
|
||||
@@ -238,9 +240,13 @@ class POSInvoiceMergeLog(Document):
|
||||
si_item.pos_invoice = doc.name
|
||||
si_item.pos_invoice_item = item.name
|
||||
if doc.is_return:
|
||||
si_item.sales_invoice_item = get_sales_invoice_item_from_consolidated_invoice(
|
||||
doc.return_against, item.pos_invoice_item
|
||||
)
|
||||
reversed_row = reversed_rows.get(item.pos_invoice_item) or frappe._dict()
|
||||
si_item.sales_invoice_item = reversed_row.get("name")
|
||||
# quote the rate of the row being reversed: rounding an invoice-level discount
|
||||
# can leave a return's net rate a minor unit above the sale's, and
|
||||
# validate_returned_items refuses a return priced above its original
|
||||
if si_item.sales_invoice_item:
|
||||
si_item.rate = reversed_row.rate
|
||||
if item.serial_and_batch_bundle:
|
||||
si_item.serial_and_batch_bundle = item.serial_and_batch_bundle
|
||||
items.append(si_item)
|
||||
@@ -432,6 +438,28 @@ class POSInvoiceMergeLog(Document):
|
||||
si.cancel()
|
||||
|
||||
|
||||
def get_reversed_rows(return_against):
|
||||
"""Rows of the consolidated sales these returns reverse, keyed by the POS invoice row."""
|
||||
if not return_against:
|
||||
return {}
|
||||
|
||||
sales_invoice = DocType("Sales Invoice")
|
||||
sales_invoice_item = DocType("Sales Invoice Item")
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(sales_invoice)
|
||||
.from_(sales_invoice_item)
|
||||
.select(sales_invoice_item.name, sales_invoice_item.rate, sales_invoice_item.pos_invoice_item)
|
||||
.where(
|
||||
(sales_invoice.name == sales_invoice_item.parent)
|
||||
& (sales_invoice.is_return == 0)
|
||||
& (sales_invoice_item.pos_invoice.isin(return_against))
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
return {row.pos_invoice_item: row for row in rows}
|
||||
|
||||
|
||||
def get_all_unconsolidated_invoices():
|
||||
filters = {
|
||||
"consolidated_invoice": ["in", ["", None]],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
|
||||
set_default_account_for_mode_of_payment,
|
||||
@@ -21,6 +23,67 @@ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@contextmanager
|
||||
def rounding_method(method):
|
||||
"""System Settings is also cached on frappe.local, so that copy has to go as well."""
|
||||
previous = frappe.db.get_single_value("System Settings", "rounding_method")
|
||||
try:
|
||||
frappe.db.set_single_value("System Settings", "rounding_method", method)
|
||||
frappe.local.system_settings = None
|
||||
yield
|
||||
finally:
|
||||
frappe.db.set_single_value("System Settings", "rounding_method", previous)
|
||||
frappe.local.system_settings = None
|
||||
|
||||
|
||||
def sell_over_the_counter(lines, discount_percentage=0):
|
||||
item_code, qty, rate = lines[0]
|
||||
sale = create_pos_invoice(item_code=item_code, qty=qty, rate=rate, do_not_save=True)
|
||||
for item_code, qty, rate in lines[1:]:
|
||||
sale.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code,
|
||||
"qty": qty,
|
||||
"rate": rate,
|
||||
"price_list_rate": rate,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"income_account": "Sales - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
if discount_percentage:
|
||||
sale.apply_discount_on = "Net Total"
|
||||
sale.additional_discount_percentage = discount_percentage
|
||||
|
||||
sale.run_method("calculate_taxes_and_totals")
|
||||
payable = sale.rounded_total or sale.grand_total
|
||||
sale.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": payable})
|
||||
sale.paid_amount = sale.base_paid_amount = payable
|
||||
sale.insert()
|
||||
sale.submit()
|
||||
return sale
|
||||
|
||||
|
||||
def refund_over_the_counter(sale, qty=None):
|
||||
"""Hand back every line of `sale`, `qty` of each when fewer units come back."""
|
||||
note = make_sales_return(sale.name)
|
||||
if qty is not None:
|
||||
for item in note.items:
|
||||
item.qty = qty
|
||||
|
||||
note.run_method("calculate_taxes_and_totals")
|
||||
refundable = note.rounded_total or note.grand_total
|
||||
note.payments[0].amount = refundable
|
||||
for spare in note.payments[1:]:
|
||||
spare.amount = 0
|
||||
note.paid_amount = note.base_paid_amount = refundable
|
||||
note.insert()
|
||||
note.submit()
|
||||
return note
|
||||
|
||||
|
||||
class TestPOSInvoiceMergeLog(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft")
|
||||
@@ -479,3 +542,81 @@ class TestPOSInvoiceMergeLog(ERPNextTestSuite):
|
||||
"POS Invoice Merge Log", {"pos_closing_entry": closing_entry.name}, "company"
|
||||
)
|
||||
self.assertEqual(pos_merge_log_company, closing_entry.company)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_consolidating_returns_priced_off_a_rounded_invoice_discount(self):
|
||||
"""A return works out its own share of an invoice-level discount, so rounding can leave
|
||||
it a minor unit above the sale's, and validate_returned_items then refuses it.
|
||||
|
||||
Every shape that reaches a consolidated credit note goes through one closing entry:
|
||||
a split landing on a half minor unit, the same item on two rows so the rows can only
|
||||
be paired through sales_invoice_item, fewer units coming back than went out, and — as
|
||||
a control — a sale with no invoice-level discount to split at all.
|
||||
"""
|
||||
for item_code in ("_Test Item", "_Test Item 2"):
|
||||
make_stock_entry(to_warehouse="_Test Warehouse - _TC", item_code=item_code, rate=100, qty=40)
|
||||
|
||||
with rounding_method("Banker's Rounding (legacy)"):
|
||||
tied = sell_over_the_counter(
|
||||
[("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)], discount_percentage=25
|
||||
)
|
||||
repeated = sell_over_the_counter(
|
||||
[("_Test Item", 1, 42.86), ("_Test Item", 1, 57.14)], discount_percentage=25
|
||||
)
|
||||
oversold = sell_over_the_counter(
|
||||
[("_Test Item", 3, 42.86), ("_Test Item 2", 3, 57.14)], discount_percentage=25
|
||||
)
|
||||
undiscounted = sell_over_the_counter([("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)])
|
||||
|
||||
# the sale and the return really do round the split apart
|
||||
self.assertEqual(
|
||||
{item.item_code: item.net_rate for item in tied.items},
|
||||
{"_Test Item": 32.15, "_Test Item 2": 42.85},
|
||||
)
|
||||
returns = [
|
||||
refund_over_the_counter(tied),
|
||||
refund_over_the_counter(repeated),
|
||||
refund_over_the_counter(oversold, qty=-1),
|
||||
refund_over_the_counter(undiscounted),
|
||||
]
|
||||
self.assertEqual(
|
||||
{item.item_code: item.net_rate for item in returns[0].items},
|
||||
{"_Test Item": 32.14, "_Test Item 2": 42.86},
|
||||
)
|
||||
|
||||
self.make_closing_entry()
|
||||
|
||||
for pos_invoice in [tied, repeated, oversold, undiscounted, *returns]:
|
||||
pos_invoice.load_from_db()
|
||||
self.assertTrue(
|
||||
frappe.db.exists("Sales Invoice", pos_invoice.consolidated_invoice),
|
||||
f"{pos_invoice.name} was not consolidated",
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Sales Invoice", pos_invoice.consolidated_invoice, "outstanding_amount"),
|
||||
0,
|
||||
)
|
||||
|
||||
for note in returns:
|
||||
# no returned row may be priced above the row it reverses
|
||||
for row in frappe.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"parent": note.consolidated_invoice},
|
||||
fields=["item_code", "rate", "sales_invoice_item"],
|
||||
):
|
||||
self.assertTrue(row.sales_invoice_item, f"{row.item_code} lost its link to the sale")
|
||||
sold_rate = frappe.db.get_value("Sales Invoice Item", row.sales_invoice_item, "rate")
|
||||
self.assertLessEqual(row.rate, sold_rate)
|
||||
|
||||
# returns for one customer land on a single credit note, which still adds up to
|
||||
# everything handed back over the counter
|
||||
refunded = {}
|
||||
for note in returns:
|
||||
refunded[note.consolidated_invoice] = refunded.get(note.consolidated_invoice, 0) + flt(
|
||||
note.grand_total
|
||||
)
|
||||
for consolidated_name, handed_back in refunded.items():
|
||||
self.assertEqual(
|
||||
flt(frappe.db.get_value("Sales Invoice", consolidated_name, "grand_total"), 2),
|
||||
flt(handed_back, 2),
|
||||
)
|
||||
|
||||
@@ -570,7 +570,7 @@
|
||||
"link_fieldname": "pos_profile"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-26 12:07:48.597412",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Profile",
|
||||
@@ -594,6 +594,10 @@
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User"
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -108,7 +108,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
</td></tr>
|
||||
</table>`;
|
||||
|
||||
frm.set_df_property("pricing_rule_help", "options", help_content);
|
||||
frm.get_field("pricing_rule_help").html(help_content);
|
||||
frm.events.set_options_for_applicable_for(frm);
|
||||
frm.trigger("toggle_reqd_apply_on");
|
||||
},
|
||||
|
||||
@@ -12,6 +12,29 @@ 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"]
|
||||
@@ -364,6 +387,18 @@ def apply_pricing_rule(args, doc=None):
|
||||
|
||||
args = frappe._dict(args)
|
||||
|
||||
# The transaction being priced decides who may price it; doc= where the caller named one.
|
||||
# An allow-list, not a type check: any readable doctype would otherwise satisfy has_permission.
|
||||
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)
|
||||
|
||||
set_transaction_type(args)
|
||||
|
||||
# list of dictionaries
|
||||
@@ -722,14 +757,18 @@ def set_transaction_type(pricing_ctx: frappe._dict) -> None:
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_item_uoms(doctype, txt, searchfield, start, page_len, filters):
|
||||
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")})]
|
||||
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 []
|
||||
|
||||
return frappe.get_all(
|
||||
"UOM Conversion Detail",
|
||||
filters={"parent": ("in", items), "uom": ("like", f"{txt}%")},
|
||||
filters={"parent": ("in", items), "parenttype": "Item", "uom": ("like", f"{txt}%")},
|
||||
fields=["uom"],
|
||||
as_list=1,
|
||||
distinct=True,
|
||||
|
||||
@@ -133,14 +133,16 @@ def initialize_parallel_threads(docname: str):
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def start_pcv_processing(docname: str):
|
||||
# checked before the status is read: otherwise an unentitled caller learns it from whether this 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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def pause_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
@@ -157,7 +159,7 @@ def pause_pcv_processing(docname: str):
|
||||
qb.update(ppcvd).set(ppcvd.status, "Paused").where(ppcvd.name.isin(queued_dates)).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def cancel_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
|
||||
|
||||
@@ -173,7 +175,7 @@ def cancel_pcv_processing(docname: str):
|
||||
qb.update(ppcvd).set(ppcvd.status, "Cancelled").where(ppcvd.name.isin(queued_dates)).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def resume_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
@@ -258,8 +260,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
return gl_entry
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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")
|
||||
|
||||
|
||||
@@ -319,6 +319,13 @@ 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,
|
||||
{
|
||||
|
||||
@@ -1704,7 +1704,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
@@ -1757,6 +1757,18 @@
|
||||
"read": 1,
|
||||
"role": "Accounts Manager",
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Quality Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"icon": "fa fa-money",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:26.945131",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Taxes and Charges Template",
|
||||
@@ -104,6 +104,22 @@
|
||||
{
|
||||
"read": 1,
|
||||
"role": "Purchase User"
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Accounts User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"show_title_field_in_link": 1,
|
||||
|
||||
@@ -517,6 +517,10 @@ def validate_docs_for_voucher_types(doc_voucher_types):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_repost_allowed_types(doctype, txt, searchfield, start, page_len, filters):
|
||||
# Reached only from this System-Manager-only doctype's own form, so the form is the boundary.
|
||||
# `filters` is passed straight to db.get_all, which is why reaching it has to be gated.
|
||||
frappe.has_permission("Repost Accounting Ledger", throw=True)
|
||||
|
||||
if txt:
|
||||
filters.update({"document_type": ("like", f"%{txt}%")})
|
||||
|
||||
|
||||
@@ -2860,9 +2860,28 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
if doctype == "Purchase Order":
|
||||
reference_field = "inter_company_order_reference"
|
||||
# The only two targets and reference fields the callers use. 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", "sales_invoice_item"),
|
||||
"Purchase Order": ("inter_company_order_reference", "Sales Order", "sales_order_item"),
|
||||
}
|
||||
if doctype not in reference_fields:
|
||||
frappe.throw(_("Invalid doctype {0}").format(doctype), frappe.PermissionError)
|
||||
|
||||
reference_field, source_doctype, expected_fieldname = reference_fields[doctype]
|
||||
|
||||
# The targets belong to the counterpart company and the caller may legitimately not read them, so
|
||||
# the source document decides access. doc= brings User Permissions in.
|
||||
frappe.has_permission(source_doctype, doc=reference_name, throw=True)
|
||||
|
||||
# `reference_fieldname` becomes a selected column and the result key, so it has to be this target's
|
||||
# own reference field: any other item-table column would be returned from unauthorised rows.
|
||||
if reference_fieldname != expected_fieldname:
|
||||
frappe.throw(
|
||||
_("{0} is not a valid reference field for {1}").format(reference_fieldname, doctype),
|
||||
frappe.ValidationError,
|
||||
)
|
||||
|
||||
filters = {
|
||||
reference_field: reference_name,
|
||||
|
||||
@@ -46,7 +46,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
|
||||
|
||||
|
||||
@@ -3157,7 +3157,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,
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"icon": "fa fa-money",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:38.343481",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Taxes and Charges Template",
|
||||
@@ -113,6 +113,26 @@
|
||||
"role": "Sales Master Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"show_title_field_in_link": 1,
|
||||
|
||||
@@ -9,10 +9,15 @@ 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: {
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
"icon": "fa fa-truck",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-22 14:53:27.315435",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Shipping Rule",
|
||||
@@ -195,6 +195,38 @@
|
||||
"role": "Sales Master Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -52,10 +52,23 @@ 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:
|
||||
|
||||
@@ -16,6 +16,39 @@ 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 = frappe.copy_doc(self.globalTestRecords["Shipping Rule"][0])
|
||||
shipping_rule.label = "Standard Delivery"
|
||||
shipping_rule.insert()
|
||||
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")
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:51.976600",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Tax Category",
|
||||
@@ -68,6 +68,62 @@
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Item Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -145,6 +145,12 @@ def get_party_details(party, party_type, args=None):
|
||||
out = {}
|
||||
billing_address, shipping_address = None, None
|
||||
if args:
|
||||
# each names a single Address: a dict is read as a filter and get_doc would resolve it to
|
||||
# whichever Address matched.
|
||||
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"):
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-30 07:13:51.785735",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Tax Withholding Category",
|
||||
@@ -142,6 +142,26 @@
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Item Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-29 05:25:50.243710",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Tax Withholding Group",
|
||||
@@ -39,6 +39,30 @@
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Accounts User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -106,6 +106,8 @@ def get_linked_payments_for_doc(
|
||||
company: str | None = None, doctype: str | None = None, docname: str | None = None
|
||||
) -> list:
|
||||
if company and doctype and docname:
|
||||
frappe.has_permission(doctype, doc=docname, throw=True)
|
||||
|
||||
_dt = doctype
|
||||
_dn = docname
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
import frappe
|
||||
from frappe import _, msgprint, qb, scrub
|
||||
from frappe.contacts.doctype.address.address import get_company_address, get_default_address
|
||||
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
|
||||
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
|
||||
from frappe.model.utils import get_fetch_values
|
||||
from frappe.permissions import get_allowed_docs_for_doctype
|
||||
from frappe.query_builder.functions import Abs, Date, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
@@ -158,7 +159,7 @@ def _get_party_details(
|
||||
)
|
||||
set_contact_details(party_details, party, party_type)
|
||||
set_other_values(party_details, party, party_type)
|
||||
set_price_list(party_details, party, party_type, price_list, pos_profile)
|
||||
set_price_list(party_details, party, party_type, price_list, pos_profile, doctype)
|
||||
|
||||
tax_template = set_taxes(
|
||||
party.name,
|
||||
@@ -384,13 +385,33 @@ def get_default_price_list(party):
|
||||
return price_list
|
||||
|
||||
|
||||
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
|
||||
def get_permitted_price_lists(doctype=None):
|
||||
permissions = sorted(
|
||||
get_user_permissions().get("Price List", []), key=lambda p: p.get("is_default"), reverse=True
|
||||
)
|
||||
|
||||
# a permission applicable for another doctype doesn't restrict this transaction
|
||||
return get_allowed_docs_for_doctype(permissions, doctype)
|
||||
|
||||
|
||||
def get_usable_price_list(price_lists, party_doctype):
|
||||
transaction_side = "selling" if party_doctype == "Customer" else "buying"
|
||||
|
||||
for price_list in price_lists:
|
||||
details = frappe.get_cached_value(
|
||||
"Price List", price_list, ["enabled", transaction_side], as_dict=True
|
||||
)
|
||||
if details.enabled and details[transaction_side]:
|
||||
return price_list
|
||||
|
||||
|
||||
def set_price_list(party_details, party, party_type, given_price_list, pos=None, doctype=None):
|
||||
# price list
|
||||
price_list = get_permitted_documents("Price List")
|
||||
permitted_price_lists = get_permitted_price_lists(doctype)
|
||||
|
||||
# if there is only one permitted document based on user permissions, set it
|
||||
if price_list and len(price_list) == 1:
|
||||
price_list = price_list[0]
|
||||
if len(permitted_price_lists) == 1:
|
||||
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
|
||||
elif pos and party_type == "Customer":
|
||||
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
|
||||
|
||||
@@ -402,6 +423,10 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
|
||||
else:
|
||||
price_list = get_default_price_list(party) or given_price_list
|
||||
|
||||
# don't set a price list the user has no permission for, the transaction can't be saved with it
|
||||
if price_list and permitted_price_lists and price_list not in permitted_price_lists:
|
||||
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
|
||||
|
||||
if price_list and not is_price_list_enabled(price_list):
|
||||
price_list = None
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "for_revaluation_journals",
|
||||
label: __("Revaluation Journals"),
|
||||
label: __("Include Revaluation Journals"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -113,7 +113,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "for_revaluation_journals",
|
||||
label: __("Revaluation Journals"),
|
||||
label: __("Include Revaluation Journals"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -183,7 +183,7 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "for_revaluation_journals",
|
||||
label: __("Revaluation Journals"),
|
||||
label: __("Include Revaluation Journals"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -141,7 +141,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "for_revaluation_journals",
|
||||
label: __("Revaluation Journals"),
|
||||
label: __("Include 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):
|
||||
@@ -732,23 +732,32 @@ class GrossProfitGenerator:
|
||||
)
|
||||
|
||||
def get_returned_invoice_items(self):
|
||||
returned_invoices = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
si.name, si_item.item_code, si_item.sales_invoice_item, si_item.stock_qty as qty,
|
||||
si_item.base_net_amount as base_amount, si.return_against
|
||||
from
|
||||
`tabSales Invoice` si, `tabSales Invoice Item` si_item
|
||||
where
|
||||
si.name = si_item.parent
|
||||
and si.docstatus = 1
|
||||
and si.is_return = 1
|
||||
and si.posting_date between %(from_date)s and %(to_date)s
|
||||
""",
|
||||
{"from_date": self.filters.from_date, "to_date": self.filters.to_date},
|
||||
as_dict=1,
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
si_item = frappe.qb.DocType("Sales Invoice Item")
|
||||
query = (
|
||||
frappe.qb.from_(si)
|
||||
.inner_join(si_item)
|
||||
.on(si.name == si_item.parent)
|
||||
.select(
|
||||
si.name,
|
||||
si_item.item_code,
|
||||
si_item.sales_invoice_item,
|
||||
si_item.stock_qty.as_("qty"),
|
||||
si_item.base_net_amount.as_("base_amount"),
|
||||
si.return_against,
|
||||
)
|
||||
.where(
|
||||
(si.docstatus == 1)
|
||||
& (si.is_return == 1)
|
||||
& si.posting_date.between(self.filters.from_date, self.filters.to_date)
|
||||
)
|
||||
)
|
||||
|
||||
if self.filters.company:
|
||||
query = query.where(si.company == self.filters.company)
|
||||
|
||||
returned_invoices = query.run(as_dict=1)
|
||||
|
||||
self.returned_invoices = frappe._dict()
|
||||
self.legacy_returned_invoices = frappe._dict()
|
||||
for inv in returned_invoices:
|
||||
@@ -964,7 +973,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]
|
||||
@@ -1347,7 +1356,4 @@ class GrossProfitGenerator:
|
||||
).setdefault(d.parent_item, []).append(d)
|
||||
|
||||
def load_non_stock_items(self):
|
||||
self.non_stock_items = frappe.db.sql_list(
|
||||
"""select name from tabItem
|
||||
where is_stock_item=0"""
|
||||
)
|
||||
self.non_stock_items = frappe.get_all("Item", filters={"is_stock_item": 0}, pluck="name")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.accounts.party import get_default_price_list, set_price_list
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -34,19 +35,159 @@ class PartyTestCase(ERPNextTestSuite):
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def create_price_list(self, enabled):
|
||||
def test_fallback_should_not_pick_an_unpermitted_price_list(self):
|
||||
permitted_default = self.create_price_list(enabled=1)
|
||||
permitted_other = self.create_price_list(enabled=1)
|
||||
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(
|
||||
party_details, customer, "Customer", self.create_price_list(enabled=1), doctype="Sales Order"
|
||||
)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, permitted_default)
|
||||
|
||||
def test_permitted_given_price_list_should_be_kept(self):
|
||||
permitted_default = self.create_price_list(enabled=1)
|
||||
permitted_other = self.create_price_list(enabled=1)
|
||||
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", permitted_other, doctype="Sales Order")
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, permitted_other)
|
||||
|
||||
def test_permission_for_another_doctype_should_not_apply(self):
|
||||
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
|
||||
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, given_price_list)
|
||||
|
||||
def test_a_single_permitted_price_list_should_fit_the_transaction(self):
|
||||
buying_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
|
||||
user = self.create_user_with_price_list_permissions([buying_price_list])
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def test_buying_transaction_should_not_take_a_selling_price_list(self):
|
||||
permitted = [self.create_price_list(enabled=1) for _ in range(2)]
|
||||
user = self.create_user_with_price_list_permissions(permitted)
|
||||
supplier_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
|
||||
supplier = self.create_supplier(default_price_list=supplier_price_list)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, supplier, "Supplier", None, doctype="Purchase Order")
|
||||
|
||||
self.assertIsNone(party_details.buying_price_list)
|
||||
|
||||
def test_permission_for_another_doctype_should_not_apply_without_a_doctype(self):
|
||||
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
|
||||
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, given_price_list)
|
||||
|
||||
def test_pos_price_list_should_be_kept(self):
|
||||
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
|
||||
user = self.create_user_with_price_list_permissions(permitted)
|
||||
pos_price_list = self.create_price_list(enabled=1)
|
||||
pos_profile = make_pos_profile(selling_price_list=pos_price_list)
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(
|
||||
party_details, customer, "Customer", None, pos=pos_profile.name, doctype="POS Invoice"
|
||||
)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, pos_price_list)
|
||||
|
||||
def test_disabled_permitted_price_lists_should_clear_the_price_list(self):
|
||||
permitted = [self.create_price_list(enabled=0), self.create_price_list(enabled=0)]
|
||||
user = self.create_user_with_price_list_permissions(permitted)
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def create_user_with_price_list_permissions(self, price_lists, applicable_for=None):
|
||||
user = frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": f"{frappe.generate_hash(length=10)}@example.com",
|
||||
"first_name": "Price List Test",
|
||||
"send_welcome_email": 0,
|
||||
"roles": [{"role": "Sales User"}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
for idx, price_list in enumerate(price_lists):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User Permission",
|
||||
"user": user.name,
|
||||
"allow": "Price List",
|
||||
"for_value": price_list,
|
||||
"is_default": int(idx == 0),
|
||||
"apply_to_all_doctypes": int(not applicable_for),
|
||||
"applicable_for": applicable_for,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.clear_cache(user=user.name)
|
||||
self.addCleanup(frappe.clear_cache, user=user.name)
|
||||
|
||||
return user.name
|
||||
|
||||
def create_price_list(self, enabled, selling=1, buying=0):
|
||||
price_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": 1,
|
||||
"selling": selling,
|
||||
"buying": buying,
|
||||
"enabled": enabled,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return price_list.name
|
||||
|
||||
def create_supplier(self, **values):
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier",
|
||||
"supplier_name": frappe.generate_hash(length=10),
|
||||
**values,
|
||||
}
|
||||
).insert(ignore_permissions=True, ignore_mandatory=True)
|
||||
|
||||
def create_customer(self, **values):
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -627,7 +627,7 @@
|
||||
"link_fieldname": "target_asset"
|
||||
}
|
||||
],
|
||||
"modified": "2026-03-12 16:07:39.543227",
|
||||
"modified": "2026-09-22 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
@@ -663,6 +663,38 @@
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Purchase User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Stock User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "System Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -736,6 +736,7 @@ class Asset(AccountsController):
|
||||
frappe.throw(_("Asset cannot be cancelled, as it is already {0}").format(self.status))
|
||||
|
||||
def cancel_movement_entries(self):
|
||||
# filter the parent Asset Movement's docstatus (as the original SQL did), not the child row's
|
||||
movements = frappe.db.sql(
|
||||
"""SELECT asm.name, asm.docstatus
|
||||
FROM `tabAsset Movement` asm, `tabAsset Movement Item` asm_item
|
||||
@@ -1316,7 +1317,25 @@ def is_cwip_accounting_enabled(asset_category):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_asset_value_after_depreciation(asset_name, finance_book=None):
|
||||
"""Whitelisted entry point: authorise the caller, then return the value."""
|
||||
# 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)
|
||||
|
||||
# select-or-read: these roles hold `select` on Asset, which does not satisfy a `read` check.
|
||||
# Guard only here -- the in-process callers use _get_asset_value_after_depreciation() below.
|
||||
ptype = "select" if frappe.only_has_select_perm("Asset") else "read"
|
||||
frappe.has_permission("Asset", ptype, doc=asset_name, throw=True)
|
||||
|
||||
return _get_asset_value_after_depreciation(asset_name, finance_book)
|
||||
|
||||
|
||||
def _get_asset_value_after_depreciation(asset_name, finance_book=None):
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
|
||||
if not asset.calculate_depreciation:
|
||||
return flt(asset.value_after_depreciation)
|
||||
|
||||
@@ -1325,6 +1344,8 @@ def get_asset_value_after_depreciation(asset_name, finance_book=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def has_active_capitalization(asset):
|
||||
frappe.has_permission("Asset", doc=asset, throw=True)
|
||||
|
||||
active_capitalizations = frappe.db.count(
|
||||
"Asset Capitalization", filters={"target_asset": asset, "docstatus": 1}
|
||||
)
|
||||
@@ -1333,7 +1354,19 @@ def has_active_capitalization(asset):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_values_from_purchase_doc(purchase_doc_name: str, item_code: str, doctype: str):
|
||||
purchase_doc = frappe.get_doc(doctype, purchase_doc_name)
|
||||
# `doctype` is caller-supplied and reaches frappe.get_doc(), so without this list any document with
|
||||
# an `items` table could be read for its valuation rates.
|
||||
if doctype not in ("Purchase Receipt", "Purchase Invoice"):
|
||||
frappe.throw(_("Invalid document type"), frappe.PermissionError)
|
||||
|
||||
# The Asset form is the boundary: Quality Manager writes Assets but reads neither Purchase Receipt
|
||||
# nor Purchase Invoice, so the purchase document cannot be it.
|
||||
frappe.has_permission("Asset", "write", throw=True)
|
||||
|
||||
# and the purchase document itself, whose company, posting date and amount are returned below.
|
||||
# Asset write is not authority to read a Purchase Receipt or Invoice.
|
||||
purchase_doc = frappe.get_doc(doctype, purchase_doc_name, check_permission="read")
|
||||
|
||||
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]
|
||||
|
||||
if not matching_items:
|
||||
|
||||
@@ -10,7 +10,7 @@ from frappe import _
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
|
||||
from erpnext.assets.doctype.asset.asset import _get_asset_value_after_depreciation
|
||||
from erpnext.assets.doctype.asset.depreciation import (
|
||||
depreciate_asset,
|
||||
get_disposal_account_and_cost_center,
|
||||
@@ -32,7 +32,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
|
||||
from erpnext.stock.utils import _get_incoming_rate
|
||||
|
||||
force_fields = [
|
||||
"target_item_name",
|
||||
@@ -197,7 +197,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
|
||||
@@ -332,6 +332,8 @@ 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)
|
||||
@@ -342,11 +344,13 @@ 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")
|
||||
d.current_asset_value = flt(
|
||||
get_asset_value_after_depreciation(d.asset, finance_book=finance_book)
|
||||
_get_asset_value_after_depreciation(d.asset, finance_book=finance_book)
|
||||
)
|
||||
d.asset_value = get_value_after_depreciation_on_disposal_date(
|
||||
d.asset, self.posting_date, finance_book=finance_book
|
||||
@@ -640,8 +644,18 @@ class AssetCapitalization(StockController):
|
||||
)
|
||||
|
||||
|
||||
def check_capitalization_access(company=None):
|
||||
"""Every lookup in this file feeds the Asset Capitalization form, so that form is the boundary.
|
||||
|
||||
`company` is accepted so the call sites read the same as on develop, where it also narrows the
|
||||
caller to their permitted companies. There is no Company Restriction on this branch.
|
||||
"""
|
||||
frappe.has_permission("Asset Capitalization", throw=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_target_item_details(item_code: str | None = None, company: str | None = None) -> frappe._dict:
|
||||
check_capitalization_access(company)
|
||||
out = frappe._dict()
|
||||
|
||||
# Get Item Details
|
||||
@@ -668,6 +682,7 @@ 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) -> frappe._dict:
|
||||
check_capitalization_access(company)
|
||||
out = frappe._dict()
|
||||
|
||||
# Get Asset Details
|
||||
@@ -759,7 +774,7 @@ def get_warehouse_details(args):
|
||||
frappe.has_permission("Stock Ledger Entry", throw=True)
|
||||
out = {
|
||||
"actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0,
|
||||
"valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False),
|
||||
"valuation_rate": _get_incoming_rate(args, raise_error_if_no_rate=False),
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -767,6 +782,14 @@ def get_warehouse_details(args):
|
||||
@frappe.whitelist()
|
||||
@erpnext.normalize_ctx_input(ItemDetailsCtx)
|
||||
def get_consumed_asset_details(ctx):
|
||||
check_capitalization_access(ctx.get("company"))
|
||||
|
||||
# and the Asset the caller named: its depreciation values are returned through the unguarded
|
||||
# _get_asset_value_after_depreciation. select-or-read, as in the asset.py wrapper.
|
||||
if ctx.get("asset"):
|
||||
ptype = "select" if frappe.only_has_select_perm("Asset") else "read"
|
||||
frappe.has_permission("Asset", ptype, doc=ctx.get("asset"), throw=True)
|
||||
|
||||
out = frappe._dict()
|
||||
|
||||
asset_details = frappe._dict()
|
||||
@@ -783,7 +806,7 @@ def get_consumed_asset_details(ctx):
|
||||
|
||||
if ctx.asset:
|
||||
out.current_asset_value = flt(
|
||||
get_asset_value_after_depreciation(ctx.asset, finance_book=ctx.finance_book)
|
||||
_get_asset_value_after_depreciation(ctx.asset, finance_book=ctx.finance_book)
|
||||
)
|
||||
out.asset_value = get_value_after_depreciation_on_disposal_date(
|
||||
ctx.asset, ctx.posting_date, finance_book=ctx.finance_book
|
||||
@@ -813,6 +836,8 @@ def get_consumed_asset_details(ctx):
|
||||
@frappe.whitelist()
|
||||
@erpnext.normalize_ctx_input(ItemDetailsCtx)
|
||||
def get_service_item_details(ctx):
|
||||
check_capitalization_access(ctx.get("company"))
|
||||
|
||||
out = frappe._dict()
|
||||
|
||||
item = frappe._dict()
|
||||
@@ -838,6 +863,8 @@ def get_items_tagged_to_wip_composite_asset(params):
|
||||
if isinstance(params, str):
|
||||
params = json.loads(params)
|
||||
|
||||
check_capitalization_access(params.get("company") if isinstance(params, dict | frappe._dict) else None)
|
||||
|
||||
fields = [
|
||||
"item_code",
|
||||
"item_name",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-05-13 15:33:03.791814",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Category",
|
||||
@@ -116,6 +116,10 @@
|
||||
"role": "Quality Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Item Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Asset",
|
||||
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"],[\"Asset\",\"company\",\"=\",\"eval:doc.company\"]]",
|
||||
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"]]",
|
||||
"options": "Asset",
|
||||
"reqd": 1
|
||||
},
|
||||
|
||||
@@ -232,9 +232,17 @@ class DepreciationScheduleController(StraightLineMethod, WDVMethod):
|
||||
last_depr_date = add_months(
|
||||
self.fb_row.depreciation_start_date, -1 * self.fb_row.frequency_of_depreciation
|
||||
)
|
||||
if self.has_complete_opening_periods() and self.should_get_last_day:
|
||||
last_depr_date = get_last_day(last_depr_date)
|
||||
return last_depr_date
|
||||
|
||||
def get_booked_depr_for_months_count(self, last_depr_date):
|
||||
if last_depr_date and self.has_complete_opening_periods():
|
||||
first_period_end = self.get_next_schedule_date(
|
||||
-cint(self.asset_doc.opening_number_of_booked_depreciations)
|
||||
)
|
||||
return month_diff(last_depr_date, first_period_end) - 1 + self.fb_row.frequency_of_depreciation
|
||||
|
||||
depr_booked_for_months = 0
|
||||
if last_depr_date:
|
||||
asset_used_for_months = self.fb_row.frequency_of_depreciation * (
|
||||
@@ -250,6 +258,20 @@ class DepreciationScheduleController(StraightLineMethod, WDVMethod):
|
||||
)
|
||||
return depr_booked_for_months
|
||||
|
||||
def has_complete_opening_periods(self):
|
||||
if (
|
||||
self.fb_row.depreciation_method not in ("Straight Line", "Manual")
|
||||
or self.fb_row.daily_prorata_based
|
||||
or self.fb_row.shift_based
|
||||
or not self.asset_doc.opening_number_of_booked_depreciations
|
||||
):
|
||||
return False
|
||||
|
||||
first_period_start = add_days(
|
||||
self.get_next_schedule_date(-cint(self.asset_doc.opening_number_of_booked_depreciations) - 1), 1
|
||||
)
|
||||
return first_period_start == getdate(self.asset_doc.available_for_use_date)
|
||||
|
||||
def get_total_pending_days_or_years(self):
|
||||
if cint(frappe.get_single_value("Accounts Settings", "calculate_depr_using_total_days")):
|
||||
last_depr_date = self.get_last_booked_depreciation_date()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import cstr, flt, getdate
|
||||
from frappe.utils import add_months, cstr, flt, get_last_day, getdate
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.assets.doctype.asset.depreciation import (
|
||||
@@ -21,6 +21,30 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAssetDepreciationSchedule(ERPNextTestSuite):
|
||||
def test_complete_opening_periods(self):
|
||||
for available in ("2026-08-01", "2024-02-01"):
|
||||
for frequency in (1, 3, 6, 12):
|
||||
for opening in (1, 4):
|
||||
with self.subTest(available=available, frequency=frequency, opening=opening):
|
||||
start = get_last_day(add_months(available, (opening + 1) * frequency - 1))
|
||||
asset = create_asset(
|
||||
calculate_depreciation=1,
|
||||
available_for_use_date=available,
|
||||
depreciation_start_date=start,
|
||||
frequency_of_depreciation=frequency,
|
||||
total_number_of_depreciations=12,
|
||||
net_purchase_amount=13200,
|
||||
expected_value_after_useful_life=1200,
|
||||
opening_number_of_booked_depreciations=opening,
|
||||
opening_accumulated_depreciation=1000 * opening,
|
||||
)
|
||||
schedule = get_depr_schedule(asset.name, "Draft")
|
||||
self.assertEqual(
|
||||
[row.depreciation_amount for row in schedule], [1000] * (12 - opening)
|
||||
)
|
||||
self.assertEqual(schedule[-1].accumulated_depreciation_amount, 12000)
|
||||
self.assertEqual(getdate(schedule[0].schedule_date), start)
|
||||
|
||||
def test_throw_error_if_another_asset_depr_schedule_exist(self):
|
||||
asset = create_asset(item_code="Macbook Pro", calculate_depreciation=1, submit=1)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:34.976117",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Maintenance Team",
|
||||
@@ -78,6 +78,10 @@
|
||||
"role": "Manufacturing User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Quality Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -458,6 +458,15 @@ class AssetRepair(AccountsController):
|
||||
add_asset_activity(self.asset, subject)
|
||||
|
||||
|
||||
def check_asset_repair_access(company=None):
|
||||
"""Both pickers below sit on the Asset Repair form, so that form is the boundary, not Purchase Invoice.
|
||||
|
||||
`company` is accepted so the call sites read the same as on develop, where it also narrows the
|
||||
caller to their permitted companies. There is no Company Restriction on this branch.
|
||||
"""
|
||||
frappe.has_permission("Asset Repair", throw=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_downtime(failure_date, completion_date):
|
||||
downtime = time_diff_in_hours(completion_date, failure_date)
|
||||
@@ -471,6 +480,8 @@ def get_purchase_invoice(doctype, txt, searchfield, start, page_len, filters):
|
||||
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")
|
||||
@@ -506,6 +517,8 @@ def get_expense_accounts(doctype, txt, searchfield, start, page_len, filters):
|
||||
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 []
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:35.869900",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Shift Factor",
|
||||
@@ -66,6 +66,10 @@
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Quality Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
|
||||
@@ -10,7 +10,7 @@ from frappe.utils import cstr, flt, formatdate, get_link_to_form, getdate
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_checks_for_pl_and_bs_accounts,
|
||||
)
|
||||
from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
|
||||
from erpnext.assets.doctype.asset.asset import _get_asset_value_after_depreciation
|
||||
from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts
|
||||
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
@@ -61,7 +61,7 @@ class AssetValueAdjustment(Document):
|
||||
|
||||
def set_current_asset_value(self):
|
||||
if not self.current_asset_value and self.asset:
|
||||
self.current_asset_value = get_asset_value_after_depreciation(self.asset, self.finance_book)
|
||||
self.current_asset_value = _get_asset_value_after_depreciation(self.asset, self.finance_book)
|
||||
|
||||
def on_submit(self):
|
||||
self.make_asset_revaluation_entry()
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
],
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2025-04-29 13:53:13.488906",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Location",
|
||||
@@ -198,6 +198,10 @@
|
||||
"role": "Stock Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Quality Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -15,7 +15,7 @@ from erpnext.accounts.report.financial_statements import (
|
||||
validate_fiscal_year,
|
||||
)
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
|
||||
from erpnext.assets.doctype.asset.asset import _get_asset_value_after_depreciation
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
|
||||
@@ -608,7 +608,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
var item_length = me.frm.doc.items.length;
|
||||
while (i < item_length) {
|
||||
var qty = me.frm.doc.items[i].qty;
|
||||
(r.message[0] || []).forEach(function (d) {
|
||||
(r.message || []).forEach(function (d) {
|
||||
if (
|
||||
d.qty > 0 &&
|
||||
qty > 0 &&
|
||||
|
||||
@@ -768,14 +768,18 @@ def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor=
|
||||
return item_last_purchase_rate
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def close_or_unclose_purchase_orders(names, status):
|
||||
if not frappe.has_permission("Purchase Order", "write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
frappe.has_permission("Purchase Order", "write", throw=True)
|
||||
|
||||
names = json.loads(names)
|
||||
for name in names:
|
||||
po = frappe.get_lazy_doc("Purchase Order", name)
|
||||
if not isinstance(name, str):
|
||||
frappe.throw(_("Invalid name"), frappe.PermissionError)
|
||||
|
||||
# the check above is doctype level, so on its own it lets a caller restricted to one company close
|
||||
# another company's orders. Matches what update_status() already does.
|
||||
po = frappe.get_lazy_doc("Purchase Order", name, check_permission="submit")
|
||||
if po.docstatus == 1:
|
||||
if status == "Closed":
|
||||
if po.status not in ("Cancelled", "Closed") and (
|
||||
@@ -993,7 +997,7 @@ def get_list_context(context=None):
|
||||
return list_context
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def update_status(status, name):
|
||||
po = frappe.get_lazy_doc("Purchase Order", name, check_permission="submit")
|
||||
po.update_status(status)
|
||||
|
||||
@@ -796,6 +796,25 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
po = create_purchase_order(qty=3.4, do_not_save=True)
|
||||
self.assertRaises(UOMMustBeIntegerError, po.insert)
|
||||
|
||||
def test_uom_integer_check_tolerates_conversion_dust(self):
|
||||
from erpnext.utilities.transaction_base import UOMMustBeIntegerError
|
||||
|
||||
item_doc = make_item(properties={"stock_uom": "Nos"})
|
||||
item_doc.append("uoms", {"uom": "Kg", "conversion_factor": 0.6})
|
||||
item_doc.save()
|
||||
item = item_doc.name
|
||||
|
||||
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
|
||||
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
|
||||
po.items[0].uom = "Kg"
|
||||
po.items[0].conversion_factor = 0.6
|
||||
po.insert()
|
||||
|
||||
fractional = create_purchase_order(item_code=item, qty=3333.9, do_not_save=1)
|
||||
fractional.items[0].uom = "Kg"
|
||||
fractional.items[0].conversion_factor = 0.6
|
||||
self.assertRaises(UOMMustBeIntegerError, fractional.insert)
|
||||
|
||||
def test_ordered_qty_for_closing_po(self):
|
||||
bin = frappe.get_all(
|
||||
"Bin",
|
||||
|
||||
@@ -11,7 +11,6 @@ from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
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
|
||||
@@ -687,32 +686,34 @@ def get_supplier_tag():
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_rfq_containing_supplier(doctype, txt, searchfield, start, page_len, filters):
|
||||
rfq = frappe.qb.DocType("Request for Quotation")
|
||||
rfq_supplier = frappe.qb.DocType("Request for Quotation Supplier")
|
||||
rfq_filters = [
|
||||
["docstatus", "=", 1],
|
||||
["company", "=", filters.get("company")],
|
||||
]
|
||||
|
||||
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"))
|
||||
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,
|
||||
)
|
||||
.orderby(rfq.transaction_date, order=Order.asc)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
rfq_filters.append(["name", "in", parents or [""]])
|
||||
|
||||
if txt:
|
||||
query = query.where(rfq.name.like(f"%%{txt}%%"))
|
||||
rfq_filters.append(["name", "like", f"%{txt}%"])
|
||||
|
||||
if filters.get("transaction_date"):
|
||||
query = query.where(rfq.transaction_date == filters.get("transaction_date"))
|
||||
rfq_filters.append(["transaction_date", "=", filters.get("transaction_date")])
|
||||
|
||||
rfq_data = query.run(as_dict=1)
|
||||
|
||||
return rfq_data
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -562,7 +562,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-14 16:10:58.600553",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
@@ -618,6 +618,30 @@
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Delivery User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Quality Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Website Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -237,6 +237,15 @@ class Supplier(TransactionBase):
|
||||
def get_supplier_primary(doctype, txt, searchfield, start, page_len, filters):
|
||||
supplier = filters.get("supplier")
|
||||
type = filters.get("type")
|
||||
|
||||
# `type` is caller-supplied and reaches qb.DocType(), so any doctype could be joined to Dynamic
|
||||
# Link. The two pickers that call this 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")
|
||||
|
||||
|
||||
@@ -948,7 +948,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation",
|
||||
@@ -1007,6 +1007,14 @@
|
||||
"read": 1,
|
||||
"role": "Purchase Manager",
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Maintenance User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
139
erpnext/buying/test_utils.py
Normal file
139
erpnext/buying/test_utils.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
import frappe.permissions
|
||||
|
||||
from erpnext.buying.utils import get_linked_material_requests
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def create_user_with_roles(email, *roles):
|
||||
if frappe.db.exists("User", email):
|
||||
user = frappe.get_doc("User", email)
|
||||
else:
|
||||
user = frappe.new_doc("User")
|
||||
user.email = email
|
||||
user.first_name = email.split("@", 1)[0]
|
||||
user.insert(ignore_permissions=True)
|
||||
|
||||
user.set("roles", [])
|
||||
for role in roles:
|
||||
user.append("roles", {"role": role})
|
||||
user.save(ignore_permissions=True)
|
||||
|
||||
# a user left without roles is downgraded to a Website User on save
|
||||
frappe.db.set_value("User", email, "user_type", "System User")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class TestGetLinkedMaterialRequests(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.material_request = make_material_request(item_code="_Test Item")
|
||||
|
||||
def test_permitted_role_can_fetch_linked_material_requests(self):
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests(["_Test Item"])
|
||||
|
||||
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
|
||||
|
||||
def test_populated_result_is_a_flat_list_of_rows(self):
|
||||
"""Both callers iterate the response directly, so it has to stay a flat list of rows
|
||||
rather than a list of lists."""
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests(["_Test Item"])
|
||||
|
||||
self.assertIsInstance(rows, list)
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
self.assertNotIsInstance(row, list | tuple)
|
||||
self.assertIsInstance(row, dict)
|
||||
for fieldname in ("mr_name", "mr_item", "item_code", "qty"):
|
||||
self.assertIn(fieldname, row)
|
||||
|
||||
def test_empty_result_is_a_flat_empty_list(self):
|
||||
item_without_request = make_item("_Test Item Without Material Request").name
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests([item_without_request])
|
||||
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
def test_a_single_item_code_is_treated_as_one_code(self):
|
||||
"""A lone code must be read as one item code, not iterated character by character."""
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests(json.dumps("_Test Item"))
|
||||
|
||||
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
|
||||
|
||||
def test_items_that_are_not_item_codes_are_rejected(self):
|
||||
"""Anything that is not a `str` or a `list` is already refused by the type annotation,
|
||||
so these are the malformed inputs that reach the method."""
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
bad_inputs = (
|
||||
"not json at all",
|
||||
[{"item_code": "_Test Item"}],
|
||||
[["_Test Item"]],
|
||||
[None],
|
||||
)
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
for bad_items in bad_inputs:
|
||||
with self.subTest(items=bad_items):
|
||||
self.assertRaises(frappe.ValidationError, get_linked_material_requests, bad_items)
|
||||
|
||||
def test_manufacturing_manager_can_fetch_linked_material_requests(self):
|
||||
"""Manufacturing Manager holds write on Supplier Quotation and Request for Quotation,
|
||||
both of which call this method, so it must hold Material Request read as well."""
|
||||
create_user_with_roles("test_buying_mfg_manager@example.com", "Manufacturing Manager")
|
||||
|
||||
with self.set_user("test_buying_mfg_manager@example.com"):
|
||||
rows = get_linked_material_requests(["_Test Item"])
|
||||
|
||||
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
|
||||
|
||||
def test_unpermitted_role_cannot_fetch_linked_material_requests(self):
|
||||
create_user_with_roles("test_buying_sales_user@example.com", "Sales User")
|
||||
|
||||
with self.set_user("test_buying_sales_user@example.com"):
|
||||
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
|
||||
|
||||
def test_role_with_only_select_permission_cannot_fetch_linked_material_requests(self):
|
||||
"""Material Request grants Delivery and Maintenance roles `select` and nothing else.
|
||||
`select` is enough to list names, so the permitted set must be resolved through a
|
||||
filter on the child table, which requires `read`."""
|
||||
create_user_with_roles("test_buying_delivery_user@example.com", "Delivery User")
|
||||
|
||||
with self.set_user("test_buying_delivery_user@example.com"):
|
||||
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
|
||||
|
||||
def test_results_are_restricted_by_user_permissions(self):
|
||||
other_company_request = make_material_request(
|
||||
item_code="_Test Item",
|
||||
company="_Test Company 1",
|
||||
warehouse="_Test Warehouse 2 - _TC1",
|
||||
cost_center="Main - _TC1",
|
||||
)
|
||||
user = create_user_with_roles("test_buying_restricted_user@example.com", "Purchase User")
|
||||
frappe.permissions.add_user_permission("Company", "_Test Company", user.name)
|
||||
|
||||
try:
|
||||
with self.set_user(user.name):
|
||||
mr_names = {row.mr_name for row in get_linked_material_requests(["_Test Item"])}
|
||||
finally:
|
||||
frappe.permissions.remove_user_permission("Company", "_Test Company", user.name)
|
||||
|
||||
self.assertIn(self.material_request.name, mr_names)
|
||||
self.assertNotIn(other_company_request.name, mr_names)
|
||||
@@ -43,7 +43,9 @@ 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))
|
||||
frappe.db.set_value(
|
||||
"Item", d.item_code, "last_purchase_rate", flt(last_purchase_rate), update_modified=False
|
||||
)
|
||||
|
||||
|
||||
def validate_for_items(doc) -> None:
|
||||
@@ -125,7 +127,37 @@ def check_on_hold_or_closed_status(doctype, docname) -> None:
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_material_requests(items):
|
||||
items = json.loads(items)
|
||||
"""
|
||||
Retrieve Material Requests linked to a list of items.
|
||||
"""
|
||||
|
||||
try:
|
||||
items = frappe.parse_json(items)
|
||||
except (TypeError, ValueError):
|
||||
frappe.throw(_("Items must be a list of Item codes"))
|
||||
|
||||
if isinstance(items, str):
|
||||
items = [items]
|
||||
|
||||
if not isinstance(items, list | tuple) or any(not isinstance(item, str) for item in items):
|
||||
frappe.throw(_("Items must be a list of Item codes"))
|
||||
|
||||
permitted_material_requests = frappe.get_list(
|
||||
"Material Request",
|
||||
filters=[
|
||||
["material_request_type", "=", "Purchase"],
|
||||
["docstatus", "=", 1],
|
||||
["status", "!=", "Stopped"],
|
||||
["per_ordered", "<", 99.99],
|
||||
["Material Request Item", "item_code", "in", items],
|
||||
],
|
||||
pluck="name",
|
||||
distinct=True,
|
||||
)
|
||||
|
||||
if not permitted_material_requests:
|
||||
return []
|
||||
|
||||
mr_list = []
|
||||
for item in items:
|
||||
material_request = frappe.db.sql(
|
||||
@@ -135,16 +167,17 @@ def get_linked_material_requests(items):
|
||||
mr_item.name AS mr_item
|
||||
FROM `tabMaterial Request` mr, `tabMaterial Request Item` mr_item
|
||||
WHERE mr.name = mr_item.parent
|
||||
AND mr.name IN %(permitted)s
|
||||
AND mr_item.item_code = %(item)s
|
||||
AND mr.material_request_type = 'Purchase'
|
||||
AND mr.per_ordered < 99.99
|
||||
AND mr.docstatus = 1
|
||||
AND mr.status != 'Stopped'
|
||||
ORDER BY mr_item.item_code ASC""",
|
||||
{"item": item},
|
||||
{"item": item, "permitted": permitted_material_requests},
|
||||
as_dict=1,
|
||||
)
|
||||
if material_request:
|
||||
mr_list.append(material_request)
|
||||
mr_list.extend(material_request)
|
||||
|
||||
return mr_list
|
||||
|
||||
@@ -405,12 +405,26 @@ class AccountsController(TransactionBase):
|
||||
return any(item.delivered_by_supplier for item in items)
|
||||
|
||||
def validate_price_list(self):
|
||||
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
|
||||
if self.get("selling_price_list"):
|
||||
price_list_field, transaction_side = "selling_price_list", "selling"
|
||||
else:
|
||||
price_list_field, transaction_side = "buying_price_list", "buying"
|
||||
|
||||
price_list = self.get(price_list_field)
|
||||
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
|
||||
if not price_list:
|
||||
return
|
||||
|
||||
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
|
||||
details = (
|
||||
frappe.db.get_value("Price List", price_list, ["enabled", transaction_side], as_dict=True)
|
||||
or frappe._dict()
|
||||
)
|
||||
|
||||
# An internal transfer carries the price list of the outward document into the inward one.
|
||||
fits_transaction = details.get(transaction_side) or self.is_internal_transfer()
|
||||
if details.enabled and fits_transaction:
|
||||
return
|
||||
|
||||
# Returns retain a submitted voucher's pricing even if its price list no longer fits.
|
||||
if (
|
||||
self.get("is_return")
|
||||
and self.get("return_against")
|
||||
@@ -421,9 +435,20 @@ class AccountsController(TransactionBase):
|
||||
):
|
||||
return
|
||||
|
||||
if not details.enabled:
|
||||
frappe.throw(
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
)
|
||||
|
||||
if transaction_side == "selling":
|
||||
message = _("Price List {0} cannot be used on a selling transaction")
|
||||
else:
|
||||
message = _("Price List {0} cannot be used on a buying transaction")
|
||||
|
||||
frappe.throw(
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
message.format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Invalid Price List"),
|
||||
)
|
||||
|
||||
def set_default_letter_head(self):
|
||||
@@ -1066,12 +1091,15 @@ class AccountsController(TransactionBase):
|
||||
args = "for_buying"
|
||||
|
||||
if self.meta.get_field(fieldname) and self.get(fieldname):
|
||||
previous_price_list_currency = self.price_list_currency
|
||||
self.price_list_currency = frappe.db.get_value("Price List", self.get(fieldname), "currency")
|
||||
|
||||
if self.price_list_currency == self.company_currency:
|
||||
self.plc_conversion_rate = 1.0
|
||||
|
||||
elif not self.plc_conversion_rate:
|
||||
elif not self.plc_conversion_rate or (
|
||||
previous_price_list_currency and previous_price_list_currency != self.price_list_currency
|
||||
):
|
||||
self.plc_conversion_rate = get_exchange_rate(
|
||||
self.price_list_currency, self.company_currency, transaction_date, args
|
||||
)
|
||||
@@ -3243,11 +3271,28 @@ def get_tax_rate(account_head):
|
||||
return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True)
|
||||
|
||||
|
||||
# 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, master_name=None):
|
||||
"""Reject a caller-supplied doctype that is not a tax template.
|
||||
|
||||
`master_name` is accepted so the call sites read the same as on develop, where it also narrows
|
||||
the caller to their permitted companies. There is no Company Restriction on this branch.
|
||||
"""
|
||||
if master_doctype not in TAX_MASTER_DOCTYPES:
|
||||
frappe.throw(_("Invalid tax master doctype"), frappe.PermissionError)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_default_taxes_and_charges(master_doctype, tax_template=None, company=None):
|
||||
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:
|
||||
@@ -3265,6 +3310,9 @@ def get_default_taxes_and_charges(master_doctype, tax_template=None, company=Non
|
||||
def get_taxes_and_charges(master_doctype, master_name):
|
||||
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)
|
||||
@@ -4405,7 +4453,7 @@ def update_child_qty_rate(
|
||||
cancel_stock_reservation_entries(parent.doctype, parent.name)
|
||||
|
||||
if parent.per_picked == 0:
|
||||
parent.create_stock_reservation_entries()
|
||||
parent._create_stock_reservation_entries()
|
||||
|
||||
|
||||
def check_if_child_table_updated(child_table_before_update, child_table_after_update, fields_to_check):
|
||||
|
||||
@@ -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, is_serial_no_wise_valuation_disabled
|
||||
|
||||
|
||||
class QtyMismatchError(ValidationError):
|
||||
@@ -181,7 +181,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,
|
||||
@@ -467,7 +467,7 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
net_rate = item.base_net_amount
|
||||
if item.sales_incoming_rate: # for internal transfer
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
net_rate = self.get_internal_transfer_qty(item) * item.sales_incoming_rate
|
||||
|
||||
if (
|
||||
not net_rate
|
||||
@@ -683,7 +683,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"),
|
||||
@@ -803,6 +803,26 @@ class BuyingController(SubcontractingController):
|
||||
)
|
||||
)
|
||||
|
||||
def get_internal_transfer_qty(self, row) -> float:
|
||||
if flt(row.qty) or not self.is_internal_receipt():
|
||||
return flt(row.qty)
|
||||
|
||||
return flt(row.rejected_qty)
|
||||
|
||||
def is_internal_receipt(self) -> bool:
|
||||
return self.doctype == "Purchase Receipt" and self.is_internal_transfer()
|
||||
|
||||
def get_source_warehouse_qty(self, row, accepted_qty):
|
||||
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
|
||||
return accepted_qty
|
||||
|
||||
if row.get("serial_and_batch_bundle") or row.get("rejected_serial_and_batch_bundle"):
|
||||
return accepted_qty
|
||||
|
||||
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
|
||||
|
||||
return flt(accepted_qty + rejected_qty, row.precision("stock_qty"))
|
||||
|
||||
def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
|
||||
self.update_ordered_and_reserved_qty()
|
||||
|
||||
@@ -815,8 +835,9 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
if d.warehouse:
|
||||
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
|
||||
source_qty = self.get_source_warehouse_qty(d, pr_qty)
|
||||
|
||||
if pr_qty:
|
||||
if pr_qty or source_qty:
|
||||
if d.from_warehouse and (
|
||||
(not cint(self.is_return) and self.docstatus == 1)
|
||||
or (cint(self.is_return) and self.docstatus == 2)
|
||||
@@ -832,7 +853,7 @@ class BuyingController(SubcontractingController):
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": -1 * pr_qty,
|
||||
"actual_qty": -1 * source_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"outgoing_rate": d.rate,
|
||||
"recalculate_rate": 1,
|
||||
@@ -866,9 +887,11 @@ class BuyingController(SubcontractingController):
|
||||
)
|
||||
|
||||
if self.is_return:
|
||||
outgoing_rate = get_rate_for_return(
|
||||
self.doctype, self.name, d.item_code, self.return_against, item_row=d
|
||||
)
|
||||
outgoing_rate = 0.0
|
||||
if not is_serial_no_wise_valuation_disabled(d.item_code):
|
||||
outgoing_rate = get_rate_for_return(
|
||||
self.doctype, self.name, d.item_code, self.return_against, item_row=d
|
||||
)
|
||||
|
||||
sle.update(
|
||||
{
|
||||
@@ -905,7 +928,7 @@ class BuyingController(SubcontractingController):
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": -1 * pr_qty,
|
||||
"actual_qty": -1 * source_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"recalculate_rate": 1,
|
||||
"serial_and_batch_bundle": (
|
||||
|
||||
@@ -34,6 +34,10 @@ def get_variant(template, args=None, variant=None, manufacturer=None, manufactur
|
||||
:param item: Template Item
|
||||
:param args: A dictionary with "Attribute" as key and "Attribute Value" as value
|
||||
"""
|
||||
# The Item form is the boundary and `read` is loser-free: roles that cannot read Item cannot open
|
||||
# that form. The two server-side callers 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:
|
||||
@@ -309,6 +313,11 @@ def find_variant(template, args, variant_item_code=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_variant(item, args, use_template_image=False):
|
||||
# Same right as the sibling enqueue_multiple_variant_creation, 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)
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
@@ -334,7 +343,7 @@ def create_variant(item, args, use_template_image=False):
|
||||
return variant
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def enqueue_multiple_variant_creation(item, args, use_template_image=False):
|
||||
frappe.has_permission("Item", ptype="create", throw=True)
|
||||
use_template_image = frappe.parse_json(use_template_image)
|
||||
@@ -527,6 +536,9 @@ def make_variant_item_code(template_item_code, template_item_name, variant):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_variant_doc_for_quick_entry(template, args):
|
||||
# get_variant and create_variant carry their own checks; this fails fast rather than relying on them.
|
||||
frappe.has_permission("Item", doc=template, throw=True)
|
||||
|
||||
variant_based_on = frappe.db.get_value("Item", template, "variant_based_on")
|
||||
args = json.loads(args)
|
||||
if variant_based_on == "Manufacturer":
|
||||
|
||||
@@ -6,16 +6,17 @@ import json
|
||||
from collections import OrderedDict, defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import qb, scrub
|
||||
from frappe import _, qb, scrub
|
||||
from frappe.desk.reportview import get_filters_cond, get_match_cond
|
||||
from frappe.permissions import has_permission
|
||||
from frappe.query_builder import Criterion, CustomFunction
|
||||
from frappe.query_builder import Case, Criterion
|
||||
from frappe.query_builder.functions import Concat, Locate, Sum
|
||||
from frappe.utils import cint, nowdate, today, unique
|
||||
from pypika import Order
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.stock.doctype.item.item_search import get_item_search_candidates
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
@@ -176,7 +177,15 @@ def tax_account_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False):
|
||||
def item_query(
|
||||
doctype: str,
|
||||
txt: str,
|
||||
searchfield: str,
|
||||
start: int,
|
||||
page_len: int,
|
||||
filters: dict | str | None,
|
||||
as_dict: bool = False,
|
||||
):
|
||||
doctype = "Item"
|
||||
conditions = []
|
||||
|
||||
@@ -207,6 +216,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
]
|
||||
if field not in searchfields
|
||||
]
|
||||
searched_fields = list(searchfields)
|
||||
searchfields = " or ".join([field + " like %(txt)s" for field in searchfields])
|
||||
|
||||
if filters and isinstance(filters, dict):
|
||||
@@ -263,7 +273,17 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
if frappe.db.estimate_count(doctype) < 50000:
|
||||
# scan description only if items are less than 50000
|
||||
description_cond = "or tabItem.description LIKE %(txt)s"
|
||||
searched_fields.append("description")
|
||||
|
||||
candidate_cond = ""
|
||||
candidates = get_item_search_candidates(txt, searched_fields)
|
||||
if candidates is not None:
|
||||
if not candidates:
|
||||
return [] if as_dict else ()
|
||||
|
||||
candidate_cond = "and tabItem.name in %(candidates)s"
|
||||
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-sql-format-injection
|
||||
return frappe.db.sql(
|
||||
"""select
|
||||
tabItem.name {columns}
|
||||
@@ -274,7 +294,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
and (tabItem.end_of_life > %(today)s or ifnull(tabItem.end_of_life, '0000-00-00')='0000-00-00')
|
||||
and ({scond} or tabItem.item_code IN (select parent from `tabItem Barcode` where barcode LIKE %(txt)s)
|
||||
{description_cond})
|
||||
{fcond} {mcond}
|
||||
{fcond} {mcond} {candidate_cond}
|
||||
order by
|
||||
if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
|
||||
if(locate(%(_txt)s, item_name), locate(%(_txt)s, item_name), 99999),
|
||||
@@ -286,6 +306,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
fcond=get_filters_cond(doctype, filters, conditions).replace("%", "%%"),
|
||||
mcond=get_match_cond(doctype).replace("%", "%%"),
|
||||
description_cond=description_cond,
|
||||
candidate_cond=candidate_cond,
|
||||
),
|
||||
{
|
||||
"today": nowdate(),
|
||||
@@ -293,6 +314,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
"_txt": txt.replace("%", ""),
|
||||
"start": start,
|
||||
"page_len": page_len,
|
||||
"candidates": tuple(candidates or ()),
|
||||
},
|
||||
as_dict=as_dict,
|
||||
)
|
||||
@@ -336,43 +358,65 @@ def get_project_name(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
|
||||
):
|
||||
proj = qb.DocType("Project")
|
||||
qb_filter_and_conditions = []
|
||||
qb_filter_or_conditions = []
|
||||
ifelse = CustomFunction("IF", ["condition", "then", "else"])
|
||||
meta = frappe.get_meta(doctype)
|
||||
list_filters = [["status", "not in", ["Completed", "Cancelled", "On hold"]]]
|
||||
|
||||
if filters:
|
||||
if filters.get("customer"):
|
||||
qb_filter_and_conditions.append(
|
||||
(proj.customer == filters.get("customer")) | (proj.customer.isnull()) | (proj.customer == "")
|
||||
)
|
||||
# an `in` containing "" renders as `ifnull(customer,'') in (...)`: this customer, or none
|
||||
list_filters.append(["customer", "in", [filters.get("customer"), ""]])
|
||||
|
||||
if filters.get("company"):
|
||||
qb_filter_and_conditions.append(proj.company == filters.get("company"))
|
||||
|
||||
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])
|
||||
list_filters.append(["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, failing the whole call.
|
||||
searchfields = [
|
||||
x for x in frappe.get_meta(doctype).get_search_fields() if x not in ["customer", "status"]
|
||||
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)
|
||||
]
|
||||
|
||||
# pattern search
|
||||
if txt:
|
||||
for x in searchfields:
|
||||
qb_filter_or_conditions.append(proj[x].like(f"%{txt}%"))
|
||||
fields = get_fields(doctype, ["name", "project_name"])
|
||||
|
||||
q = q.where(Criterion.all(qb_filter_and_conditions)).where(Criterion.any(qb_filter_or_conditions))
|
||||
# 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 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,
|
||||
)
|
||||
|
||||
# ordering
|
||||
if txt:
|
||||
# project_name containing search string 'txt' will be given higher precedence
|
||||
q = q.orderby(ifelse(Locate(txt, proj.project_name) > 0, Locate(txt, proj.project_name), 99999))
|
||||
# with a search term, resolve the 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))
|
||||
)
|
||||
|
||||
# CASE, not IF(): IF() is MariaDB-only and is emitted verbatim, so it fails on postgres.
|
||||
q = q.orderby(
|
||||
Case().when(Locate(txt, proj.project_name) > 0, Locate(txt, proj.project_name)).else_(99999)
|
||||
)
|
||||
q = q.orderby(proj.idx, order=Order.desc).orderby(proj.name)
|
||||
|
||||
if page_len:
|
||||
@@ -636,28 +680,33 @@ def get_account_list(doctype, txt, searchfield, start, page_len, filters):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters):
|
||||
bo = frappe.qb.DocType("Blanket Order")
|
||||
bo_item = frappe.qb.DocType("Blanket Order Item")
|
||||
bo_filters = [
|
||||
["docstatus", "=", 1],
|
||||
["blanket_order_type", "=", filters.get("blanket_order_type")],
|
||||
["company", "=", filters.get("company")],
|
||||
]
|
||||
|
||||
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)
|
||||
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,
|
||||
)
|
||||
)
|
||||
bo_filters.append(["name", "in", parents or [""]])
|
||||
|
||||
if currency := filters.get("currency"):
|
||||
query = query.where(bo.currency == currency)
|
||||
bo_filters.append(["currency", "=", currency])
|
||||
|
||||
return query.run()
|
||||
return frappe.get_list(
|
||||
"Blanket Order",
|
||||
filters=bo_filters,
|
||||
fields=["name", "blanket_order_type", "to_date"],
|
||||
group_by="name",
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -839,15 +888,22 @@ def get_doctype_wise_filters(filters):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_batch_numbers(doctype, txt, searchfield, start, page_len, filters):
|
||||
query = """select batch_id from `tabBatch`
|
||||
where disabled = 0
|
||||
and (expiry_date >= CURRENT_DATE or expiry_date IS NULL)
|
||||
and name like {txt}""".format(txt=frappe.db.escape(f"%{txt}%"))
|
||||
# get_list applies the select check and the caller's record-level conditions together
|
||||
batch_filters = [["disabled", "=", 0], ["name", "like", f"%{txt}%"]]
|
||||
|
||||
if filters and filters.get("item"):
|
||||
query += " and item = {item}".format(item=frappe.db.escape(filters.get("item")))
|
||||
batch_filters.append(["item", "=", filters.get("item")])
|
||||
|
||||
return frappe.db.sql(query, filters)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -872,35 +928,67 @@ def item_manufacturer_query(doctype, txt, searchfield, start, page_len, filters)
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_purchase_receipts(doctype, txt, searchfield, start, page_len, filters):
|
||||
query = """
|
||||
select pr.name
|
||||
from `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pritem
|
||||
where pr.docstatus = 1 and pritem.parent = pr.name
|
||||
and pr.name like {txt}""".format(txt=frappe.db.escape(f"%{txt}%"))
|
||||
pr_filters = [["docstatus", "=", 1], ["name", "like", f"%{txt}%"]]
|
||||
|
||||
if filters and filters.get("item_code"):
|
||||
query += " and pritem.item_code = {item_code}".format(
|
||||
item_code=frappe.db.escape(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 [""]])
|
||||
|
||||
return frappe.db.sql(query, filters)
|
||||
# group_by, not distinct: 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,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_purchase_invoices(doctype, txt, searchfield, start, page_len, filters):
|
||||
query = """
|
||||
select pi.name
|
||||
from `tabPurchase Invoice` pi, `tabPurchase Invoice Item` piitem
|
||||
where pi.docstatus = 1 and piitem.parent = pi.name
|
||||
and pi.name like {txt}""".format(txt=frappe.db.escape(f"%{txt}%"))
|
||||
pi_filters = [["docstatus", "=", 1], ["name", "like", f"%{txt}%"]]
|
||||
|
||||
if filters and filters.get("item_code"):
|
||||
query += " and piitem.item_code = {item_code}".format(
|
||||
item_code=frappe.db.escape(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 [""]])
|
||||
|
||||
return frappe.db.sql(query, filters)
|
||||
# group_by, not distinct: 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,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -983,9 +1071,28 @@ def get_fields(doctype, fields=None):
|
||||
def get_payment_terms_for_references(doctype, txt, searchfield, start, page_len, filters) -> list:
|
||||
terms = []
|
||||
if filters:
|
||||
reference = filters.get("reference")
|
||||
if not reference:
|
||||
return terms
|
||||
|
||||
# only a plain name names one document: a filter operator 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 with no permissions of its own, so its parent decides access.
|
||||
# Prefer the caller's own reference type: the lookup cannot tell two same-named parents apart.
|
||||
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": filters.get("reference")},
|
||||
filters={"parent": reference, "parenttype": parenttype},
|
||||
fields=["payment_term"],
|
||||
limit=page_len,
|
||||
as_list=1,
|
||||
@@ -996,6 +1103,30 @@ def get_payment_terms_for_references(doctype, txt, searchfield, start, page_len,
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters) -> list:
|
||||
parent = filters.get("parent") if filters else None
|
||||
|
||||
# a plain name, never a filter operator: ["like", "%"] would span parents, only one of which is
|
||||
# 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: any other 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 parent decides access. Read the parent
|
||||
# type off the rows, not off `filters`, so the document authorised is the one 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)
|
||||
@@ -1020,7 +1151,11 @@ def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters)
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
if frappe.get_single_value("Stock Settings", "allow_uom_with_conversion_rate_defined_in_item"):
|
||||
query_filters = {"parent": filters.get("item_code")}
|
||||
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"}
|
||||
|
||||
if txt:
|
||||
query_filters["uom"] = ["like", f"%{txt}%"]
|
||||
@@ -1035,7 +1170,7 @@ def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
return frappe.get_all(
|
||||
return frappe.get_list(
|
||||
"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_combine_datetime, get_incoming_rate, get_valuation_method, getdate
|
||||
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method, getdate
|
||||
|
||||
|
||||
class StockOverReturnError(frappe.ValidationError):
|
||||
@@ -812,7 +812,7 @@ def get_rate_for_return(
|
||||
rate = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "incoming_rate")
|
||||
|
||||
if not rate and sle:
|
||||
rate = get_incoming_rate(
|
||||
rate = _get_incoming_rate(
|
||||
{
|
||||
"item_code": sle.item_code,
|
||||
"warehouse": sle.warehouse,
|
||||
@@ -1300,14 +1300,38 @@ 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):
|
||||
# `invoice` may be a Sales Invoice or a POS Invoice -- both share the Sales Invoice Payment child
|
||||
# table -- so resolve which before authorising.
|
||||
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, invoice):
|
||||
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, invoice, customer, item_row_name):
|
||||
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)
|
||||
@@ -1315,6 +1339,8 @@ def get_invoice_item_returned_qty(doctype, invoice, customer, item_row_name):
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_invoice_returnable(doctype, invoice):
|
||||
validate_returnable_invoice(doctype, invoice)
|
||||
|
||||
is_return, docstatus, customer = frappe.db.get_value(
|
||||
doctype, invoice, ["is_return", "docstatus", "customer"]
|
||||
)
|
||||
|
||||
@@ -12,7 +12,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_combine_datetime, get_incoming_rate, get_valuation_method
|
||||
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method
|
||||
|
||||
|
||||
class SellingController(StockController):
|
||||
@@ -573,7 +573,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):
|
||||
@@ -99,7 +99,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
|
||||
@@ -859,7 +859,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))
|
||||
@@ -1068,7 +1068,7 @@ class SubcontractingController(StockController):
|
||||
link = get_link_to_form(
|
||||
self.subcontract_data.order_doctype, row.get(self.subcontract_data.order_field)
|
||||
)
|
||||
msg = f'The Batch No {frappe.bold(row.get("batch_no"))} has not supplied against the {self.subcontract_data.order_doctype} {link}'
|
||||
msg = f"The Batch No {frappe.bold(row.get('batch_no'))} has not supplied against the {self.subcontract_data.order_doctype} {link}"
|
||||
frappe.throw(_(msg), title=_("Incorrect Batch Consumed"))
|
||||
|
||||
def __validate_serial_no(self, row, key):
|
||||
@@ -1247,7 +1247,7 @@ class SubcontractingController(StockController):
|
||||
and reset_outgoing_rate
|
||||
and frappe.get_cached_value("Item", item.rm_item_code, "is_stock_item")
|
||||
):
|
||||
rate = get_incoming_rate(
|
||||
rate = _get_incoming_rate(
|
||||
{
|
||||
"item_code": item.rm_item_code,
|
||||
"warehouse": self.supplier_warehouse,
|
||||
|
||||
59
erpnext/controllers/tests/test_price_list_currency.py
Normal file
59
erpnext/controllers/tests/test_price_list_currency.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
|
||||
class TestPriceListCurrency(UnitTestCase):
|
||||
def test_price_list_currency_transition(self):
|
||||
cases = (
|
||||
("USD", "CDF", 1, 0.000444444, True),
|
||||
("EUR", "CDF", 1.2, 0.000444444, True),
|
||||
("CDF", "USD", 0.000444444, 1, False),
|
||||
("CDF", "CDF", 0.0005, 0.0005, False),
|
||||
("CDF", "CDF", 0, 0.000444444, True),
|
||||
(None, "CDF", 0.0005, 0.0005, False),
|
||||
)
|
||||
for direction in ("Selling", "Buying"):
|
||||
for previous_currency, currency, previous_rate, expected_rate, fetch_rate in cases:
|
||||
with self.subTest(
|
||||
direction=direction,
|
||||
previous_currency=previous_currency,
|
||||
currency=currency,
|
||||
previous_rate=previous_rate,
|
||||
):
|
||||
doc = frappe._dict(
|
||||
meta=Mock(),
|
||||
posting_date="2026-09-18",
|
||||
selling_price_list="New Selling Price List",
|
||||
buying_price_list="New Buying Price List",
|
||||
price_list_currency=previous_currency,
|
||||
plc_conversion_rate=previous_rate,
|
||||
company_currency="USD",
|
||||
currency="CDF",
|
||||
conversion_rate=0.000444444,
|
||||
)
|
||||
with (
|
||||
patch("erpnext.controllers.accounts_controller.frappe") as mock_frappe,
|
||||
patch(
|
||||
"erpnext.controllers.accounts_controller.get_exchange_rate",
|
||||
return_value=0.000444444,
|
||||
) as exchange_rate,
|
||||
):
|
||||
mock_frappe.db.get_value.return_value = currency
|
||||
mock_frappe.db.get_single_value.return_value = False
|
||||
AccountsController.set_price_list_currency(doc, direction)
|
||||
self.assertEqual(doc.price_list_currency, currency)
|
||||
self.assertEqual(doc.plc_conversion_rate, expected_rate)
|
||||
self.assertEqual(doc.conversion_rate, 0.000444444)
|
||||
if fetch_rate:
|
||||
exchange_rate.assert_called_once_with(
|
||||
currency, "USD", "2026-09-18", f"for_{direction.lower()}"
|
||||
)
|
||||
else:
|
||||
exchange_rate.assert_not_called()
|
||||
93
erpnext/controllers/tests/test_price_list_validation.py
Normal file
93
erpnext/controllers/tests/test_price_list_validation.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPriceListValidation(ERPNextTestSuite):
|
||||
def create_price_list(self, selling=0, buying=0, enabled=1):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": selling,
|
||||
"buying": buying,
|
||||
"enabled": enabled,
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
def test_selling_transaction_should_reject_a_buying_price_list(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = self.create_price_list(buying=1)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "selling transaction"):
|
||||
invoice.save()
|
||||
|
||||
def test_buying_transaction_should_reject_a_selling_price_list(self):
|
||||
invoice = make_purchase_invoice(do_not_save=1)
|
||||
invoice.buying_price_list = self.create_price_list(selling=1)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "buying transaction"):
|
||||
invoice.save()
|
||||
|
||||
def test_a_price_list_for_both_sides_should_be_accepted(self):
|
||||
price_list = self.create_price_list(selling=1, buying=1)
|
||||
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = price_list
|
||||
invoice.save()
|
||||
|
||||
self.assertEqual(invoice.selling_price_list, price_list)
|
||||
|
||||
def test_a_missing_price_list_should_report_rather_than_crash(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = frappe.generate_hash(length=10)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
invoice.validate_price_list()
|
||||
|
||||
def test_internal_transfer_should_keep_the_outward_price_list(self):
|
||||
"""The inward document of an internal transfer takes the price list of the outward one, which
|
||||
is flagged for the opposite side."""
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
company = "_Test Company with perpetual inventory"
|
||||
selling_only = self.create_price_list(selling=1)
|
||||
|
||||
delivery_note = create_delivery_note(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
cost_center="Main - TCP1",
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
warehouse="Stores - TCP1",
|
||||
target_warehouse=create_warehouse("_Test Transit For Price List", company=company),
|
||||
do_not_submit=1,
|
||||
)
|
||||
delivery_note.selling_price_list = selling_only
|
||||
delivery_note.save()
|
||||
delivery_note.submit()
|
||||
|
||||
receipt = make_inter_company_purchase_receipt(delivery_note.name)
|
||||
receipt.items[0].warehouse = "Stores - TCP1"
|
||||
receipt.save()
|
||||
|
||||
self.assertEqual(receipt.buying_price_list, selling_only)
|
||||
|
||||
def test_disabled_price_list_should_still_report_as_disabled(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = self.create_price_list(selling=1, enabled=0)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
invoice.save()
|
||||
@@ -543,7 +543,7 @@
|
||||
"idx": 5,
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"modified": "2026-02-10 14:36:37.157961",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Lead",
|
||||
@@ -600,6 +600,10 @@
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User"
|
||||
},
|
||||
{
|
||||
"role": "Support Team",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -484,6 +484,14 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None):
|
||||
def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
|
||||
"""raise a issue from email"""
|
||||
|
||||
# Communication grants read to `All` only for the owner and carries a has_permission hook, so doc=
|
||||
# is what decides access.
|
||||
frappe.has_permission("Communication", doc=communication, throw=True)
|
||||
|
||||
# both paths end in a Lead: the insert path checks `create`, but the path reusing an existing Lead
|
||||
# required nothing at all.
|
||||
frappe.has_permission("Lead", ptype="create", throw=True)
|
||||
|
||||
doc = frappe.get_doc("Communication", communication)
|
||||
lead_name = None
|
||||
if doc.sender:
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-12-17 12:09:34.687368",
|
||||
"modified": "2026-09-16 11:42:07.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Market Segment",
|
||||
@@ -36,6 +36,10 @@
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -285,7 +285,7 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
|
||||
}
|
||||
|
||||
if (this.frm.is_new() && this.frm.doc.opportunity_type === undefined) {
|
||||
this.frm.doc.opportunity_type = __("Sales");
|
||||
this.frm.doc.opportunity_type = "Sales";
|
||||
}
|
||||
this.setup_queries();
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
|
||||
def set_opportunity_type(self):
|
||||
if self.is_new() and not self.opportunity_type:
|
||||
self.opportunity_type = _("Sales")
|
||||
self.opportunity_type = "Sales"
|
||||
|
||||
def set_exchange_rate(self):
|
||||
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
@@ -555,8 +555,13 @@ def make_opportunity_from_communication(
|
||||
):
|
||||
from erpnext.crm.doctype.lead.lead import make_lead_from_communication
|
||||
|
||||
# Communication grants read to `All` only for the owner and carries a has_permission hook, so doc=
|
||||
# is what decides access.
|
||||
frappe.has_permission("Communication", doc=communication, throw=True)
|
||||
|
||||
doc = frappe.get_doc("Communication", communication)
|
||||
|
||||
# make_lead_from_communication() checks, but is skipped when the email already references a Lead.
|
||||
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,6 +150,10 @@ def link_open_events(ref_doctype, ref_docname, doc):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_open_activities(ref_doctype, ref_docname):
|
||||
# both arguments are caller supplied and the ToDo/Event rows are read with get_all, so the
|
||||
# referenced document decides who may see its activities. doc= applies User Permissions.
|
||||
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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user