mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-20 20:07:15 +00:00
Compare commits
66 Commits
codex/seri
...
pot_develo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4a473f3df | ||
|
|
fe25746feb | ||
|
|
f5f956c4dd | ||
|
|
fe9d6e5a57 | ||
|
|
bec627c3eb | ||
|
|
bee358ea25 | ||
|
|
000dcfc23d | ||
|
|
825d24f406 | ||
|
|
e6f431a8d6 | ||
|
|
5dfd21cce6 | ||
|
|
6f5f2cfce1 | ||
|
|
142976a829 | ||
|
|
1dd0b7dc9e | ||
|
|
b481083ff0 | ||
|
|
be8208e7cb | ||
|
|
4671d1a665 | ||
|
|
5f216c5d55 | ||
|
|
4d95a240bb | ||
|
|
86821ac6ee | ||
|
|
bc2fa03730 | ||
|
|
e6b8e90ad9 | ||
|
|
d82c35aae9 | ||
|
|
b1e99a70ac | ||
|
|
d5e63b8a9e | ||
|
|
3be0c7801a | ||
|
|
33a066d568 | ||
|
|
cf6aeddc29 | ||
|
|
3f422f8e0d | ||
|
|
f130c64530 | ||
|
|
681bd2734f | ||
|
|
31205c4114 | ||
|
|
503a80f2c9 | ||
|
|
3a8bd852d5 | ||
|
|
a8ec43bcf8 | ||
|
|
e2b2940452 | ||
|
|
3761eb8cbe | ||
|
|
fd492100b0 | ||
|
|
28bd1332de | ||
|
|
4b23cee2ea | ||
|
|
bdf124ab06 | ||
|
|
b2bdeaa672 | ||
|
|
bcade8f0ba | ||
|
|
4c32acf300 | ||
|
|
db9e93306a | ||
|
|
e93ca84398 | ||
|
|
afd93cf867 | ||
|
|
51fb261b6b | ||
|
|
f8c2f3440b | ||
|
|
991cea5ae2 | ||
|
|
11d2847d3e | ||
|
|
e0d6d797d7 | ||
|
|
d5a9d158f9 | ||
|
|
fa89552d10 | ||
|
|
cee9f4949a | ||
|
|
218e7927ff | ||
|
|
36a4dfe797 | ||
|
|
e825bb2f74 | ||
|
|
f864333afa | ||
|
|
c412310eb5 | ||
|
|
1728d1b0f5 | ||
|
|
79fc039092 | ||
|
|
467f54162f | ||
|
|
f60c349794 | ||
|
|
cadc0ca86d | ||
|
|
2f572b1624 | ||
|
|
13031d6d5d |
@@ -222,6 +222,13 @@ class AccountsSettings(Document):
|
||||
set_allow_on_submit_for_dimension_fields(doctypes)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def get_posting_date_confirmation() -> int:
|
||||
return cint(
|
||||
frappe.db.get_single_value("Accounts Settings", "confirm_before_resetting_posting_date", cache=False)
|
||||
)
|
||||
|
||||
|
||||
def toggle_accounting_dimension_sections(hide):
|
||||
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")
|
||||
for doctype in accounting_dimension_doctypes:
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.accounts_settings.accounts_settings import get_posting_date_confirmation
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAccountsSettings(ERPNextTestSuite):
|
||||
def test_posting_date_confirmation_uses_current_setting(self):
|
||||
for enabled in (0, 1, 0):
|
||||
frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", enabled)
|
||||
self.assertEqual(get_posting_date_confirmation(), enabled)
|
||||
|
||||
def test_stale_days(self):
|
||||
cur_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
|
||||
cur_settings.allow_stale = 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: str):
|
||||
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: str, company: str):
|
||||
|
||||
|
||||
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(".")
|
||||
@@ -179,6 +185,8 @@ def get_coa(
|
||||
):
|
||||
"""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
|
||||
|
||||
@@ -326,6 +334,8 @@ def build_response_as_excel(writer):
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_template(file_type: str, template_type: str, company: str):
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
writer = get_template(template_type, company)
|
||||
|
||||
if file_type == "CSV":
|
||||
@@ -378,7 +388,6 @@ def get_sample_template(writer, company):
|
||||
return writer
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_accounts(file_doc: Document, extension: str):
|
||||
if extension == "csv":
|
||||
accounts = generate_data_from_csv(file_doc, as_dict=True)
|
||||
|
||||
@@ -17,7 +17,8 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_address_display
|
||||
from frappe.utils import getdate
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, getdate
|
||||
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
@@ -147,6 +148,31 @@ class Dunning(AccountsController):
|
||||
)
|
||||
row.dunning_level = len(past_dunnings) + 1
|
||||
|
||||
def get_unpaid_base_dunning_amount(self):
|
||||
"""Interest and dunning fee that is still to be collected, in company currency."""
|
||||
if not self.base_dunning_amount:
|
||||
return 0.0
|
||||
|
||||
return flt(
|
||||
flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name),
|
||||
self.precision("base_dunning_amount"),
|
||||
)
|
||||
|
||||
def get_unpaid_dunning_amount(self):
|
||||
"""Interest and dunning fee that is still to be collected, in the dunning currency."""
|
||||
return flt(
|
||||
self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1),
|
||||
self.precision("dunning_amount"),
|
||||
)
|
||||
|
||||
def get_unpaid_overdue_payments(self):
|
||||
"""Overdue payments with their outstanding as of now, not as of dunning creation."""
|
||||
return [
|
||||
(row, outstanding)
|
||||
for row in self.overdue_payments
|
||||
if (outstanding := get_current_outstanding(row)) > 0
|
||||
]
|
||||
|
||||
def on_cancel(self):
|
||||
super().on_cancel()
|
||||
self.ignore_linked_doctypes = [
|
||||
@@ -161,6 +187,7 @@ class Dunning(AccountsController):
|
||||
"Unreconcile Payment Entries",
|
||||
"Payment Ledger Entry",
|
||||
"Serial and Batch Bundle",
|
||||
"Payment Entry",
|
||||
]
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -259,11 +286,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
|
||||
if has_outstanding:
|
||||
break
|
||||
|
||||
new_status = "Resolved" if not has_outstanding else "Unresolved"
|
||||
set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True)
|
||||
|
||||
if dunning.status != new_status:
|
||||
dunning.status = new_status
|
||||
dunning.save()
|
||||
|
||||
def update_dunnings_linked_to_payment(payment_entry):
|
||||
"""Refresh dunnings whose interest and fee are settled by this payment."""
|
||||
dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning}
|
||||
|
||||
for name in dunnings:
|
||||
dunning = frappe.get_doc("Dunning", name)
|
||||
if dunning.docstatus != 1:
|
||||
continue
|
||||
|
||||
set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments()))
|
||||
|
||||
|
||||
def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False):
|
||||
"""A dunning is only resolved once the invoiced sum *and* its interest and fee are paid."""
|
||||
has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0
|
||||
new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved"
|
||||
|
||||
# resolving by hand waives the interest, only an invoice that is owed again reopens it
|
||||
if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments:
|
||||
return
|
||||
|
||||
if dunning.status != new_status:
|
||||
dunning.db_set("status", new_status, notify=True)
|
||||
|
||||
|
||||
def get_paid_dunning_amount(dunning: str) -> float:
|
||||
"""Interest and fee collected for this dunning, in company currency."""
|
||||
deduction = frappe.qb.DocType("Payment Entry Deduction")
|
||||
payment_entry = frappe.qb.DocType("Payment Entry")
|
||||
|
||||
paid = (
|
||||
frappe.qb.from_(deduction)
|
||||
.join(payment_entry)
|
||||
.on(payment_entry.name == deduction.parent)
|
||||
.select(Sum(deduction.amount))
|
||||
.where((deduction.dunning == dunning) & (payment_entry.docstatus == 1))
|
||||
).run()
|
||||
|
||||
# the dunning amount is booked as a negative deduction, against the income account
|
||||
return -flt(paid[0][0]) if paid else 0.0
|
||||
|
||||
|
||||
def get_current_outstanding(overdue_payment) -> float:
|
||||
"""Outstanding of an overdue payment as of now, in the invoice's transaction currency."""
|
||||
invoice = frappe.db.get_value(
|
||||
"Sales Invoice",
|
||||
overdue_payment.sales_invoice,
|
||||
["outstanding_amount", "currency", "party_account_currency"],
|
||||
as_dict=True,
|
||||
)
|
||||
schedule_outstanding = (
|
||||
flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding"))
|
||||
if overdue_payment.payment_schedule
|
||||
else flt(overdue_payment.outstanding)
|
||||
)
|
||||
|
||||
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
|
||||
return 0.0
|
||||
|
||||
outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding))
|
||||
if invoice.currency == invoice.party_account_currency:
|
||||
outstanding = min(outstanding, flt(invoice.outstanding_amount))
|
||||
|
||||
return outstanding
|
||||
|
||||
|
||||
def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
|
||||
@@ -55,6 +55,125 @@ class TestDunning(ERPNextTestSuite):
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self):
|
||||
"""
|
||||
Regression for #58220: paying the invoice without the interest and fee must not
|
||||
resolve the dunning, the interest is still owed and has to stay claimable.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
sales_invoice = dunning.overdue_payments[0].sales_invoice
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice)
|
||||
pe.reference_no, pe.reference_date = "4", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0)
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
# the interest and fee can still be collected on their own
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
pe.reference_no, pe.reference_date = "5", nowdate()
|
||||
self.assertEqual(pe.references, [])
|
||||
self.assertEqual(round(pe.paid_amount, 2), 10.41)
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
self.assertEqual(dunning.get_unpaid_dunning_amount(), 0)
|
||||
|
||||
# cancelling the interest payment makes the dunning claimable again
|
||||
pe.cancel()
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
def test_dunning_can_be_cancelled_after_its_interest_was_paid(self):
|
||||
"""
|
||||
The payment collecting the interest links back to the dunning, which must not stand in
|
||||
the way of cancelling it.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
pe.reference_no, pe.reference_date = "6", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
dunning.cancel()
|
||||
self.assertEqual(dunning.docstatus, 2)
|
||||
|
||||
def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self):
|
||||
"""
|
||||
Resolving a dunning by hand waives its interest, so a later payment of the invoice
|
||||
must not reopen it.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
sales_invoice = dunning.overdue_payments[0].sales_invoice
|
||||
|
||||
# what the "Resolve" button does
|
||||
dunning.reload()
|
||||
dunning.status = "Resolved"
|
||||
dunning.save()
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice)
|
||||
pe.reference_no, pe.reference_date = "7", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
|
||||
)
|
||||
def test_unpaid_dunning_amount_is_tracked_in_company_currency(self):
|
||||
"""
|
||||
The interest and fee are collected as a Payment Entry deduction, a company currency
|
||||
field, so what is left to collect has to be measured in the same currency.
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
posting_date=add_days(today(), -15),
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
rate=100,
|
||||
debit_to="Debtors - _TC",
|
||||
)
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(si.name)
|
||||
dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC")
|
||||
dunning.dunning_type = dunning_type.name
|
||||
dunning.rate_of_interest = dunning_type.rate_of_interest
|
||||
dunning.dunning_fee = dunning_type.dunning_fee
|
||||
dunning.income_account = dunning_type.income_account
|
||||
dunning.cost_center = dunning_type.cost_center
|
||||
dunning.save()
|
||||
|
||||
self.assertEqual(dunning.currency, "USD")
|
||||
self.assertEqual(dunning.conversion_rate, 50)
|
||||
self.assertEqual(round(dunning.dunning_amount, 2), 10.41)
|
||||
self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55)
|
||||
|
||||
# nothing collected yet, in either currency
|
||||
self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55)
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
# the deduction booking the interest is in company currency
|
||||
dunning.submit()
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
self.assertEqual(round(pe.deductions[0].amount, 2), -520.55)
|
||||
|
||||
def test_fetch_overdue_payments(self):
|
||||
"""
|
||||
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.
|
||||
|
||||
@@ -136,6 +136,7 @@ frappe.ui.form.on("Invoice Discounting", {
|
||||
],
|
||||
primary_action: function () {
|
||||
var data = d.get_values();
|
||||
data.company = frm.doc.company;
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_import": 1,
|
||||
"autoname": "ACC-INV-DISC-.YYYY.-.#####",
|
||||
"creation": "2019-03-07 12:01:56.296952",
|
||||
@@ -170,7 +171,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:52.746196",
|
||||
"modified": "2026-09-09 17:04:59.512294",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Invoice Discounting",
|
||||
@@ -187,14 +188,15 @@
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController):
|
||||
@frappe.whitelist()
|
||||
def get_invoices(filters: str | dict):
|
||||
filters = frappe._dict(frappe.parse_json(filters))
|
||||
|
||||
if not filters.get("company"):
|
||||
frappe.throw(_("Please set company on the Document before requesting for invoices."))
|
||||
|
||||
frappe.has_permission("Company", doc=filters.get("company"), throw=True)
|
||||
frappe.has_permission("Invoice Discounting", throw=True)
|
||||
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
di = frappe.qb.DocType("Discounted Invoice")
|
||||
|
||||
|
||||
@@ -624,8 +624,8 @@ Object.assign(erpnext.journal_entry, {
|
||||
total_credit += flt(row.credit, precision("credit", row));
|
||||
});
|
||||
|
||||
frm.doc.total_debit = total_debit;
|
||||
frm.doc.total_credit = total_credit;
|
||||
frm.doc.total_debit = flt(total_debit, precision("total_debit"));
|
||||
frm.doc.total_credit = flt(total_credit, precision("total_credit"));
|
||||
frm.doc.difference = flt(total_debit - total_credit, precision("difference"));
|
||||
["total_debit", "total_credit", "difference"].forEach((field) => frm.refresh_field(field));
|
||||
},
|
||||
|
||||
@@ -674,12 +674,14 @@ class JournalEntry(AccountsController):
|
||||
if d.debit and d.credit:
|
||||
frappe.throw(_("You cannot credit and debit same account at the same time"))
|
||||
|
||||
self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit"))
|
||||
self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit"))
|
||||
self.total_debit = flt(
|
||||
self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit")
|
||||
)
|
||||
self.total_credit = flt(
|
||||
self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit")
|
||||
)
|
||||
|
||||
self.difference = flt(self.total_debit, self.precision("total_debit")) - flt(
|
||||
self.total_credit, self.precision("total_credit")
|
||||
)
|
||||
self.difference = flt(self.total_debit - self.total_credit, self.precision("difference"))
|
||||
|
||||
def validate_multi_currency(self):
|
||||
alternate_currency = []
|
||||
|
||||
@@ -461,6 +461,59 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.check_gl_entries()
|
||||
|
||||
def make_jv_with_fractional_totals(self):
|
||||
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.posting_date = nowdate()
|
||||
jv.company = "_Test Company"
|
||||
jv.voucher_type = "Journal Entry"
|
||||
jv.remark = "test"
|
||||
for amount in (0.10, 0.20):
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Cash - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit_in_account_currency": amount,
|
||||
},
|
||||
)
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"credit_in_account_currency": 0.30,
|
||||
},
|
||||
)
|
||||
jv.insert()
|
||||
return jv
|
||||
|
||||
def test_totals_are_rounded_to_precision(self):
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
stored = frappe.db.get_value(
|
||||
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
|
||||
)
|
||||
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
|
||||
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
|
||||
self.assertEqual(jv.total_debit, stored.total_debit)
|
||||
self.assertEqual(jv.total_credit, stored.total_credit)
|
||||
self.assertEqual(jv.difference, stored.difference)
|
||||
|
||||
def test_update_after_submit_with_fractional_totals(self):
|
||||
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
jv.pay_to_recd_from = "_Test Supplier"
|
||||
jv.save()
|
||||
|
||||
self.assertEqual(jv.docstatus, 1)
|
||||
self.assertEqual(
|
||||
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
|
||||
)
|
||||
|
||||
def test_jv_account_and_party_balance_with_cost_centre(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
|
||||
@@ -46,23 +46,27 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
frm.set_query("paid_from", function () {
|
||||
frm.set_query("paid_from", function (doc) {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
|
||||
filters.name = ["!=", doc.paid_to];
|
||||
}
|
||||
|
||||
return {
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
filters,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -106,21 +110,25 @@ frappe.ui.form.on("Payment Entry", {
|
||||
}
|
||||
});
|
||||
|
||||
frm.set_query("paid_to", function () {
|
||||
frm.set_query("paid_to", function (doc) {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
|
||||
filters.name = ["!=", doc.paid_from];
|
||||
}
|
||||
return {
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
filters,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ class PaymentEntry(AccountsController):
|
||||
self.set_liability_account()
|
||||
self.set_missing_ref_details(force=True)
|
||||
self.validate_payment_type()
|
||||
self.validate_internal_transfer_accounts()
|
||||
self.validate_party_details()
|
||||
self.set_exchange_rate()
|
||||
self.validate_mandatory()
|
||||
@@ -208,9 +209,15 @@ class PaymentEntry(AccountsController):
|
||||
self.update_payment_schedule()
|
||||
self.make_gl_entries()
|
||||
self.update_outstanding_amounts()
|
||||
self.update_linked_dunnings()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def update_linked_dunnings(self):
|
||||
from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment
|
||||
|
||||
update_dunnings_linked_to_payment(self)
|
||||
|
||||
def validate_for_repost(self):
|
||||
validate_docs_for_voucher_types(["Payment Entry"])
|
||||
validate_docs_for_deferred_accounting([self.name], [])
|
||||
@@ -315,6 +322,7 @@ class PaymentEntry(AccountsController):
|
||||
self.update_payment_schedule(cancel=1)
|
||||
self.make_gl_entries(cancel=1)
|
||||
self.update_outstanding_amounts()
|
||||
self.update_linked_dunnings()
|
||||
self.delink_advance_entry_references()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
@@ -627,6 +635,10 @@ class PaymentEntry(AccountsController):
|
||||
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
|
||||
frappe.throw(_("Payment Type must be one of Receive, Pay, or Internal Transfer"))
|
||||
|
||||
def validate_internal_transfer_accounts(self):
|
||||
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
|
||||
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
|
||||
|
||||
def validate_party_details(self):
|
||||
if self.party and not frappe.db.exists(self.party_type, self.party):
|
||||
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
|
||||
@@ -2725,7 +2737,7 @@ def get_payment_entry(
|
||||
pe.append("references", reference)
|
||||
else:
|
||||
if dt == "Dunning":
|
||||
for overdue_payment in doc.overdue_payments:
|
||||
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
|
||||
pe.append(
|
||||
"references",
|
||||
{
|
||||
@@ -2733,21 +2745,23 @@ def get_payment_entry(
|
||||
"reference_name": overdue_payment.sales_invoice,
|
||||
"payment_term": overdue_payment.payment_term,
|
||||
"due_date": overdue_payment.due_date,
|
||||
"total_amount": overdue_payment.outstanding,
|
||||
"outstanding_amount": overdue_payment.outstanding,
|
||||
"allocated_amount": overdue_payment.outstanding,
|
||||
"total_amount": outstanding,
|
||||
"outstanding_amount": outstanding,
|
||||
"allocated_amount": outstanding,
|
||||
},
|
||||
)
|
||||
|
||||
pe.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": doc.income_account,
|
||||
"cost_center": doc.cost_center,
|
||||
"amount": -1 * doc.dunning_amount,
|
||||
"description": _("Interest and/or dunning fee"),
|
||||
},
|
||||
)
|
||||
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
|
||||
pe.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": doc.income_account,
|
||||
"cost_center": doc.cost_center,
|
||||
"amount": -1 * unpaid_dunning_amount,
|
||||
"description": _("Interest and/or dunning fee"),
|
||||
"dunning": doc.name,
|
||||
},
|
||||
)
|
||||
else:
|
||||
pe.append(
|
||||
"references",
|
||||
@@ -3040,8 +3054,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
|
||||
grand_total = doc.rounded_total or doc.grand_total
|
||||
outstanding_amount = doc.outstanding_amount
|
||||
elif dt == "Dunning":
|
||||
grand_total = doc.grand_total
|
||||
outstanding_amount = doc.grand_total
|
||||
# only what is left to collect, the totals on the dunning are the ones it was raised with
|
||||
grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments())
|
||||
grand_total += doc.get_unpaid_dunning_amount()
|
||||
outstanding_amount = grand_total
|
||||
else:
|
||||
if party_account_currency == doc.company_currency:
|
||||
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
|
||||
|
||||
@@ -782,6 +782,23 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
self.validate_gl_entries(pe.name, expected_gle)
|
||||
|
||||
def test_internal_transfer_rejects_same_account(self):
|
||||
pe = frappe.new_doc("Payment Entry")
|
||||
pe.payment_type = "Internal Transfer"
|
||||
pe.company = "_Test Company"
|
||||
pe.paid_from = "_Test Bank - _TC"
|
||||
pe.paid_to = "_Test Bank - _TC"
|
||||
pe.paid_amount = 100
|
||||
pe.received_amount = 100
|
||||
pe.reference_no = "same-account-transfer"
|
||||
pe.reference_date = nowdate()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Paid From and Paid To accounts must be different",
|
||||
pe.insert,
|
||||
)
|
||||
|
||||
def test_bank_charges_deduction(self):
|
||||
bank_charges_account = create_account(
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"amount",
|
||||
"column_break_2",
|
||||
"is_exchange_gain_loss",
|
||||
"description"
|
||||
"description",
|
||||
"dunning"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -55,12 +56,21 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "System Generated",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "dunning",
|
||||
"fieldtype": "Link",
|
||||
"label": "Dunning",
|
||||
"no_copy": 1,
|
||||
"options": "Dunning",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-11 14:26:11.312950",
|
||||
"modified": "2026-08-17 11:20:35.482913",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry Deduction",
|
||||
|
||||
@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
|
||||
amount: DF.Currency
|
||||
cost_center: DF.Link
|
||||
description: DF.SmallText | None
|
||||
dunning: DF.Link | None
|
||||
is_exchange_gain_loss: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
|
||||
@@ -1041,40 +1041,6 @@ class TestPaymentRequestV2Gateway(ERPNextTestSuite):
|
||||
mock_payments.utils = mock_utils
|
||||
return {"payments": mock_payments, "payments.utils": mock_utils}, mock_utils
|
||||
|
||||
def test_is_v2_gateway_returns_false_for_none(self):
|
||||
"""_is_v2_gateway returns False for None input."""
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
|
||||
|
||||
# Mock returns True, but is_v2_gateway(None) in payments.utils returns False
|
||||
modules, mock_utils = self._mock_payments_modules(False)
|
||||
|
||||
with patch.dict(sys.modules, modules):
|
||||
result = _is_v2_gateway(None)
|
||||
self.assertFalse(result)
|
||||
mock_utils.is_v2_gateway.assert_called_once_with(None)
|
||||
|
||||
def test_is_v2_gateway_returns_false_for_empty_string(self):
|
||||
"""_is_v2_gateway returns False for empty string input."""
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
|
||||
|
||||
modules, mock_utils = self._mock_payments_modules(False)
|
||||
|
||||
with patch.dict(sys.modules, modules):
|
||||
result = _is_v2_gateway("")
|
||||
self.assertFalse(result)
|
||||
mock_utils.is_v2_gateway.assert_called_once_with("")
|
||||
|
||||
def test_is_v2_gateway_returns_false_for_nonexistent_gateway(self):
|
||||
"""_is_v2_gateway returns False for nonexistent gateway."""
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
|
||||
|
||||
modules, mock_utils = self._mock_payments_modules(False)
|
||||
|
||||
with patch.dict(sys.modules, modules):
|
||||
result = _is_v2_gateway("NonExistentGateway12345")
|
||||
self.assertFalse(result)
|
||||
mock_utils.is_v2_gateway.assert_called_once_with("NonExistentGateway12345")
|
||||
|
||||
def test_is_v2_gateway_delegates_to_payments_util(self):
|
||||
"""_is_v2_gateway delegates to payments.utils.is_v2_gateway."""
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
|
||||
|
||||
@@ -5,7 +5,7 @@ frappe.ui.form.on("Period Closing Voucher", {
|
||||
onload: function (frm) {
|
||||
if (!frm.doc.transaction_date) frm.doc.transaction_date = frappe.datetime.obj_to_str(new Date());
|
||||
|
||||
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher"];
|
||||
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher", "MapReduce Job"];
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
|
||||
@@ -5,9 +5,20 @@
|
||||
import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
|
||||
from frappe import _, qb
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Count, Max, Min, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
ceil,
|
||||
cint,
|
||||
flt,
|
||||
fmt_money,
|
||||
formatdate,
|
||||
get_datetime,
|
||||
get_link_to_form,
|
||||
getdate,
|
||||
)
|
||||
|
||||
from erpnext import is_perpetual_inventory_enabled
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
@@ -265,8 +276,17 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
self.make_gl_entries()
|
||||
else:
|
||||
ppcv = frappe.get_doc({"doctype": "Process Period Closing Voucher", "parent_pcv": self.name})
|
||||
ppcv.save().submit()
|
||||
from frappe.utils.background_jobs import mapreduce
|
||||
|
||||
data = self.get_data_for_mapreduce()
|
||||
mapreduce(
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.mapper",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.summarize_and_post_ledger",
|
||||
data,
|
||||
self.doctype,
|
||||
self.name,
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = (
|
||||
@@ -275,11 +295,16 @@ class PeriodClosingVoucher(AccountsController):
|
||||
"Payment Ledger Entry",
|
||||
"Account Closing Balance",
|
||||
"Process Period Closing Voucher",
|
||||
"MapReduce Job",
|
||||
)
|
||||
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.validate_accounts_not_frozen(for_cancellation=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
from frappe.utils.background_jobs import cancel_mapreduce_job
|
||||
|
||||
cancel_mapreduce_job(self.doctype, self.name)
|
||||
self.cancel_process_pcv_docs()
|
||||
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
@@ -292,6 +317,11 @@ class PeriodClosingVoucher(AccountsController):
|
||||
|
||||
def on_trash(self):
|
||||
super().on_trash()
|
||||
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
from frappe.utils.background_jobs import remove_mapreduce_job
|
||||
|
||||
remove_mapreduce_job(self.doctype, self.name)
|
||||
|
||||
ppcvs = frappe.db.get_all(
|
||||
"Process Period Closing Voucher", {"parent_pcv": self.name, "docstatus": ["in", [1, 2]]}
|
||||
)
|
||||
@@ -594,6 +624,135 @@ class PeriodClosingVoucher(AccountsController):
|
||||
{"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
|
||||
)
|
||||
|
||||
def get_data_for_mapreduce(self):
|
||||
return self.generate_tasks_for_normal_balance() + self.generate_tasks_for_opening_balance()
|
||||
|
||||
def get_period_range_for_tasks(self, start_date, end_date, step_size, report_type, balance_type):
|
||||
start_date = getdate(start_date)
|
||||
end_date = getdate(end_date)
|
||||
|
||||
# split period into date ranges
|
||||
curr_date = getdate(start_date)
|
||||
date_splits = []
|
||||
while True:
|
||||
next_date = getdate(add_days(curr_date, step_size))
|
||||
if next_date < end_date:
|
||||
date_splits.append(
|
||||
{
|
||||
"from_date": str(curr_date),
|
||||
"to_date": str(next_date),
|
||||
"pcv": self.name,
|
||||
"report_type": report_type,
|
||||
"balance_type": balance_type,
|
||||
}
|
||||
)
|
||||
curr_date = getdate(add_days(next_date, 1))
|
||||
else:
|
||||
date_splits.append(
|
||||
{
|
||||
"from_date": str(curr_date),
|
||||
"to_date": str(end_date),
|
||||
"pcv": self.name,
|
||||
"report_type": report_type,
|
||||
"balance_type": balance_type,
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
return date_splits
|
||||
|
||||
def generate_tasks_for_normal_balance(self):
|
||||
# estimation can be wrong by a factor of 2
|
||||
gl = qb.DocType("GL Entry")
|
||||
raw_query = (
|
||||
qb.from_(gl)
|
||||
.select(Count(gl.star))
|
||||
.where(
|
||||
gl.is_cancelled.eq(0) & gl.posting_date.between(self.period_start_date, self.period_end_date)
|
||||
)
|
||||
.get_sql()
|
||||
)
|
||||
|
||||
# estimation can be wrong by a factor of 2
|
||||
correction_factor = 2
|
||||
if frappe.db.db_type == "postgres":
|
||||
analyzer = frappe.json.loads(
|
||||
(
|
||||
frappe.db.sql(
|
||||
f"explain (format json) {raw_query}",
|
||||
)
|
||||
)[0][0]
|
||||
)
|
||||
|
||||
estimated_count = analyzer[0].get("Plan").get("Plans")[0].get("Plan Rows") * correction_factor
|
||||
else:
|
||||
estimated_count = (
|
||||
cint(
|
||||
frappe.db.sql(
|
||||
f"explain {raw_query}",
|
||||
as_dict=True,
|
||||
)[0].rows
|
||||
)
|
||||
* correction_factor
|
||||
)
|
||||
|
||||
job_count = (
|
||||
1 if estimated_count / 2000000 < 1 else ceil(estimated_count / 2000000)
|
||||
) # conservative chunk size
|
||||
days = (getdate(self.period_end_date) - getdate(self.period_start_date)).days
|
||||
step_size = 1 if days / job_count < 1 else ceil(days / job_count)
|
||||
return self.get_period_range_for_tasks(
|
||||
self.period_start_date, self.period_end_date, step_size, "Balance Sheet", "Normal Balance"
|
||||
) + self.get_period_range_for_tasks(
|
||||
self.period_start_date, self.period_end_date, step_size, "Profit and Loss", "Normal Balance"
|
||||
)
|
||||
|
||||
def generate_tasks_for_opening_balance(self):
|
||||
tasks = []
|
||||
if self.is_first_period_closing_voucher():
|
||||
gl = qb.DocType("GL Entry")
|
||||
min = qb.from_(gl).select(Min(gl.posting_date)).run()[0][0]
|
||||
max = qb.from_(gl).select(Max(gl.posting_date)).run()[0][0]
|
||||
|
||||
raw_query = (
|
||||
qb.from_(gl)
|
||||
.select(Count(gl.star))
|
||||
.where(gl.is_cancelled.eq(0) & gl.is_opening.eq("Yes") & gl.posting_date.between(min, max))
|
||||
.get_sql()
|
||||
)
|
||||
|
||||
# estimation can be wrong by a factor of 2
|
||||
correction_factor = 2
|
||||
if frappe.db.db_type == "postgres":
|
||||
analyzer = frappe.json.loads(
|
||||
(
|
||||
frappe.db.sql(
|
||||
f"explain (format json) {raw_query}",
|
||||
)
|
||||
)[0][0]
|
||||
)
|
||||
|
||||
estimated_count = analyzer[0].get("Plan").get("Plans")[0].get("Plan Rows") * correction_factor
|
||||
else:
|
||||
estimated_count = (
|
||||
cint(
|
||||
frappe.db.sql(
|
||||
f"explain {raw_query};",
|
||||
as_dict=True,
|
||||
)[0].rows
|
||||
)
|
||||
* correction_factor
|
||||
)
|
||||
|
||||
job_count = (
|
||||
1 if estimated_count / 2000000 < 1 else ceil(estimated_count / 2000000)
|
||||
) # conservative chunk size
|
||||
days = (getdate(self.period_end_date) - getdate(self.period_start_date)).days
|
||||
step_size = 1 if days / job_count < 1 else ceil(days / job_count)
|
||||
tasks = self.get_period_range_for_tasks(min, max, step_size, "Balance Sheet", "Opening Balance")
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def process_gl_and_closing_entries(doc):
|
||||
from erpnext.accounts.general_ledger import make_gl_entries
|
||||
@@ -673,3 +832,119 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
|
||||
order_by="period_end_date desc",
|
||||
)
|
||||
return prev_closed_period_end_date
|
||||
|
||||
|
||||
def mapper(val):
|
||||
start_date = val.from_date
|
||||
end_date = val.to_date
|
||||
pcv = val.pcv
|
||||
report_type = val.report_type
|
||||
balance_type = val.balance_type
|
||||
company = frappe.db.get_value("Period Closing Voucher", pcv, "company")
|
||||
dimensions = get_dimensions()
|
||||
|
||||
accounts = frappe.db.get_all(
|
||||
"Account", filters={"company": company, "report_type": report_type}, pluck="name"
|
||||
)
|
||||
|
||||
gle = qb.DocType("GL Entry")
|
||||
query = qb.from_(gle).select(gle.account)
|
||||
for dim in dimensions:
|
||||
query = query.select(gle[dim])
|
||||
query = query.select(
|
||||
Sum(gle.debit).as_("debit"),
|
||||
Sum(gle.credit).as_("credit"),
|
||||
Sum(gle.debit_in_account_currency).as_("debit_in_account_currency"),
|
||||
Sum(gle.credit_in_account_currency).as_("credit_in_account_currency"),
|
||||
# account_currency is constant per grouped account -> Max() keeps the GROUP BY postgres-valid
|
||||
Max(gle.account_currency).as_("account_currency"),
|
||||
ConstantColumn(balance_type).as_("balance_type"),
|
||||
ConstantColumn(report_type).as_("report_type"),
|
||||
).where(
|
||||
(gle.company.eq(company))
|
||||
& (gle.is_cancelled.eq(0))
|
||||
& (gle.posting_date.between(start_date, end_date))
|
||||
& (gle.account.isin(accounts))
|
||||
)
|
||||
|
||||
if balance_type == "Opening Balance":
|
||||
query = query.where(gle.is_opening.eq("Yes"))
|
||||
else:
|
||||
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
|
||||
query = query.where(gle.is_opening.eq("No"))
|
||||
|
||||
query = query.groupby(gle.account)
|
||||
for dim in dimensions:
|
||||
query = query.groupby(gle[dim])
|
||||
|
||||
res = query.run(as_dict=True)
|
||||
return res
|
||||
|
||||
|
||||
def reducer(final, partial_res):
|
||||
if final is None:
|
||||
final = []
|
||||
|
||||
if partial_res:
|
||||
final.extend([frappe._dict(x) for x in partial_res])
|
||||
|
||||
return final
|
||||
|
||||
|
||||
def get_dimensions():
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
|
||||
default_dimensions = ["cost_center", "finance_book", "project"]
|
||||
dimensions = default_dimensions + get_accounting_dimensions()
|
||||
return dimensions
|
||||
|
||||
|
||||
def summarize_and_post_ledger(result, ref_dt, ref_dn):
|
||||
pcv = frappe.get_doc(ref_dt, ref_dn)
|
||||
|
||||
from erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher import (
|
||||
build_dimension_wise_balance_dict,
|
||||
get_bs_closing_entries,
|
||||
get_closing_account_closing_entry,
|
||||
get_gle_for_closing_account,
|
||||
get_gle_for_pl_account,
|
||||
get_p_l_closing_entries,
|
||||
)
|
||||
|
||||
result = [frappe._dict(x) for x in result]
|
||||
|
||||
# generate and post closing entries for P&L accounts
|
||||
pl_entries = [x for x in result if x.report_type == "Profit and Loss"]
|
||||
pl_dimension_wise_acc_balance = build_dimension_wise_balance_dict(pl_entries)
|
||||
|
||||
# build gl map
|
||||
pl_accounts_reverse_gle = []
|
||||
closing_account_gle = []
|
||||
|
||||
for dimensions, account_balances in pl_dimension_wise_acc_balance.items():
|
||||
for acc, balances in account_balances.items():
|
||||
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
|
||||
if balance_in_company_currency:
|
||||
pl_accounts_reverse_gle.append(get_gle_for_pl_account(pcv, acc, balances, dimensions))
|
||||
|
||||
closing_account_gle.append(get_gle_for_closing_account(pcv, account_balances["balances"], dimensions))
|
||||
|
||||
gl_entries = pl_accounts_reverse_gle + closing_account_gle
|
||||
if gl_entries:
|
||||
from erpnext.accounts.general_ledger import make_gl_entries
|
||||
|
||||
make_gl_entries(gl_entries, merge_entries=False)
|
||||
|
||||
# generate and post account closing balance for balance sheet accounts
|
||||
bs_entries = [x for x in result if x.report_type == "Balance Sheet"]
|
||||
bs_dimension_wise_acc_balance = build_dimension_wise_balance_dict(bs_entries)
|
||||
pl_closing_entries = get_p_l_closing_entries(pl_accounts_reverse_gle, pcv)
|
||||
bs_closing_entries = get_bs_closing_entries(bs_dimension_wise_acc_balance, pcv)
|
||||
closing_entries_for_closing_account = get_closing_account_closing_entry(closing_account_gle, pcv)
|
||||
closing_entries = pl_closing_entries + bs_closing_entries + closing_entries_for_closing_account
|
||||
|
||||
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
|
||||
|
||||
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from frappe import _
|
||||
|
||||
|
||||
def get_data():
|
||||
return {
|
||||
"non_standard_fieldnames": {"MapReduce Job": "document_name"},
|
||||
"transactions": [{"label": _("Job"), "items": ["MapReduce Job"]}],
|
||||
}
|
||||
@@ -78,7 +78,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
const me = this;
|
||||
super.refresh();
|
||||
|
||||
hide_fields(this.frm.doc);
|
||||
hide_fields(this.frm);
|
||||
// Show / Hide button
|
||||
this.show_general_ledger();
|
||||
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm);
|
||||
@@ -418,7 +418,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
|
||||
is_paid() {
|
||||
hide_fields(this.frm.doc);
|
||||
hide_fields(this.frm);
|
||||
if (cint(this.frm.doc.is_paid)) {
|
||||
this.frm.set_value("allocate_advances_automatically", 0);
|
||||
this.frm.set_value("payment_terms_template", "");
|
||||
@@ -482,28 +482,26 @@ cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice);
|
||||
|
||||
// Hide Fields
|
||||
// ------------
|
||||
function hide_fields(doc) {
|
||||
var parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
|
||||
function hide_fields(frm) {
|
||||
const doc = frm.doc;
|
||||
const parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
|
||||
|
||||
if (cint(doc.is_paid) == 1) {
|
||||
hide_field(parent_fields);
|
||||
frm.toggle_display(parent_fields, false);
|
||||
} else {
|
||||
for (var i in parent_fields) {
|
||||
var docfield = frappe.meta.docfield_map[doc.doctype][parent_fields[i]];
|
||||
if (!docfield.hidden) unhide_field(parent_fields[i]);
|
||||
for (const fieldname of parent_fields) {
|
||||
const docfield = frappe.meta.docfield_map[doc.doctype][fieldname];
|
||||
if (!docfield.hidden) frm.toggle_display(fieldname, true);
|
||||
}
|
||||
}
|
||||
|
||||
var item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
|
||||
const item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
|
||||
|
||||
if (cur_frm.fields_dict["items"]) {
|
||||
cur_frm.fields_dict["items"].grid.set_column_disp(
|
||||
item_fields_stock,
|
||||
cint(doc.update_stock) == 1 || cint(doc.is_return) == 1 ? true : false
|
||||
);
|
||||
if (frm.fields_dict["items"]) {
|
||||
frm.fields_dict["items"].grid.set_column_disp(item_fields_stock, cint(doc.update_stock) == 1);
|
||||
}
|
||||
|
||||
cur_frm.refresh_fields();
|
||||
frm.refresh_fields();
|
||||
}
|
||||
|
||||
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
|
||||
@@ -712,7 +710,7 @@ frappe.ui.form.on("Purchase Invoice", {
|
||||
},
|
||||
|
||||
update_stock: function (frm) {
|
||||
hide_fields(frm.doc);
|
||||
hide_fields(frm);
|
||||
frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock ? true : false);
|
||||
},
|
||||
|
||||
|
||||
@@ -3061,6 +3061,23 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
self.assertRaises(StockOverReturnError, return_doc.save)
|
||||
|
||||
def test_partial_returns_ignore_received_qty_without_update_stock(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
invoice = make_purchase_invoice(qty=10, received_qty=10)
|
||||
|
||||
first_return = make_return_doc(invoice.doctype, invoice.name)
|
||||
first_return.items[0].qty = -4
|
||||
first_return.save().submit()
|
||||
|
||||
self.assertEqual(first_return.items[0].received_qty, -10)
|
||||
|
||||
second_return = make_return_doc(invoice.doctype, invoice.name)
|
||||
second_return.items[0].qty = -6
|
||||
second_return.save().submit()
|
||||
|
||||
self.assertEqual(second_return.docstatus, 1)
|
||||
|
||||
def test_apply_discount_on_grand_total(self):
|
||||
"""
|
||||
To test if after applying discount on grand total,
|
||||
|
||||
@@ -123,6 +123,8 @@ class RepostPaymentLedger(Document):
|
||||
def execute_repost_payment_ledger(docname: str):
|
||||
"""Repost Payment Ledger Entries by background job."""
|
||||
|
||||
frappe.has_permission("Repost Payment Ledger", ptype="submit", doc=docname, throw=True)
|
||||
|
||||
job_name = "payment_ledger_repost_" + docname
|
||||
|
||||
frappe.enqueue(
|
||||
|
||||
@@ -26,6 +26,7 @@ import erpnext
|
||||
from erpnext import get_company_currency
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.exceptions import InvalidAccountCurrency, PartyDisabled, PartyFrozen
|
||||
from erpnext.stock.doctype.price_list.price_list import is_price_list_enabled
|
||||
from erpnext.utilities.regional import temporary_flag
|
||||
|
||||
try:
|
||||
@@ -394,12 +395,17 @@ def set_other_values(party_details, party, party_type):
|
||||
|
||||
|
||||
def get_default_price_list(party):
|
||||
"""Return default price list for party (Document object)"""
|
||||
if party.get("default_price_list"):
|
||||
return party.default_price_list
|
||||
"""Return the first enabled default price list for party (Document object)"""
|
||||
price_list = party.get("default_price_list")
|
||||
if is_price_list_enabled(price_list):
|
||||
return price_list
|
||||
|
||||
if party.doctype == "Customer":
|
||||
return frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
|
||||
if party.doctype != "Customer":
|
||||
return
|
||||
|
||||
price_list = frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
|
||||
if is_price_list_enabled(price_list):
|
||||
return price_list
|
||||
|
||||
|
||||
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
|
||||
@@ -412,7 +418,7 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
|
||||
elif pos and party_type == "Customer":
|
||||
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
|
||||
|
||||
if customer_price_list:
|
||||
if is_price_list_enabled(customer_price_list):
|
||||
price_list = customer_price_list
|
||||
else:
|
||||
pos_price_list = frappe.get_value("POS Profile", pos, "selling_price_list")
|
||||
@@ -420,6 +426,9 @@ 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
|
||||
|
||||
if price_list and not is_price_list_enabled(price_list):
|
||||
price_list = None
|
||||
|
||||
if price_list:
|
||||
party_details.price_list_currency = frappe.db.get_value(
|
||||
"Price List", price_list, "currency", cache=True
|
||||
|
||||
@@ -171,6 +171,7 @@ class ReceivablePayableReport:
|
||||
party_account=ple.account,
|
||||
posting_date=ple.posting_date,
|
||||
account_currency=ple.account_currency,
|
||||
cost_center=ple.cost_center,
|
||||
remarks=ple.remarks,
|
||||
invoiced=0.0,
|
||||
paid=0.0,
|
||||
|
||||
@@ -1337,6 +1337,28 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
row = report[1][0]
|
||||
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
|
||||
|
||||
def test_cost_center_on_payment_before_invoice(self):
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"party": [self.customer],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.posting_date = add_days(today(), 1)
|
||||
si.due_date = si.posting_date
|
||||
si.payment_schedule[0].due_date = si.posting_date
|
||||
si.save().submit()
|
||||
|
||||
pe = self.create_payment_entry(si.name, do_not_submit=True)
|
||||
pe.cost_center = self.cost_center
|
||||
pe.save().submit()
|
||||
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == pe.name)
|
||||
self.assertEqual(row.cost_center, pe.cost_center)
|
||||
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
|
||||
@@ -28,16 +28,16 @@
|
||||
<br>{%= __("Clearance Date") %}: {%= frappe.datetime.str_to_user(data[i]["clearance_date"]) %}
|
||||
{% } %}
|
||||
</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
|
||||
</tr>
|
||||
{% } else { %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>{%= data[i]["payment_entry"] %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
|
||||
</tr>
|
||||
{% } %}
|
||||
{% } %}
|
||||
|
||||
@@ -114,6 +114,7 @@ def execute(filters=None):
|
||||
filters={
|
||||
"account_type": row["account_type"],
|
||||
"is_group": 0,
|
||||
"company": filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
@@ -180,13 +180,15 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
columns[0]["fieldname"] = "sales_invoice"
|
||||
columns[0]["options"] = "Item"
|
||||
columns[0]["width"] = 300
|
||||
# removing Item Code and Item Name columns
|
||||
# removing the duplicate Item Code column and moving Item Name before Customer
|
||||
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
||||
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
|
||||
if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name":
|
||||
del columns[4:6]
|
||||
del columns[4]
|
||||
columns.insert(1, columns.pop(4))
|
||||
else:
|
||||
del columns[5:7]
|
||||
del columns[5]
|
||||
columns.insert(1, columns.pop(5))
|
||||
|
||||
total_base_amount = 0
|
||||
total_buying_amount = 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.party import get_default_price_list
|
||||
from erpnext.accounts.party import get_default_price_list, set_price_list
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -16,3 +16,46 @@ class PartyTestCase(ERPNextTestSuite):
|
||||
customer.save()
|
||||
price_list = get_default_price_list(customer)
|
||||
assert price_list is None
|
||||
|
||||
def test_disabled_party_default_should_fall_back_to_given_price_list(self):
|
||||
customer = self.create_customer(default_price_list=self.create_price_list(enabled=0))
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
set_price_list(party_details, customer, "Customer", given_price_list)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, given_price_list)
|
||||
|
||||
def test_disabled_given_price_list_should_not_be_set(self):
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
set_price_list(party_details, customer, "Customer", self.create_price_list(enabled=0))
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def create_price_list(self, enabled):
|
||||
price_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": 1,
|
||||
"enabled": enabled,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return price_list.name
|
||||
|
||||
def create_customer(self, **values):
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": frappe.generate_hash(length=10),
|
||||
**values,
|
||||
}
|
||||
).insert(ignore_permissions=True, ignore_mandatory=True)
|
||||
customer.customer_group = None
|
||||
customer.save()
|
||||
|
||||
return customer
|
||||
|
||||
@@ -581,7 +581,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 &&
|
||||
|
||||
@@ -226,6 +226,7 @@ class PurchaseOrder(BuyingController):
|
||||
self.doctype, self.supplier, self.company, self.inter_company_order_reference
|
||||
)
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
self.set_missing_terms()
|
||||
|
||||
def set_has_unit_price_items(self):
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -129,7 +129,33 @@ def get_linked_material_requests(items: str | list):
|
||||
Retrieve Material Requests linked to a list of items.
|
||||
"""
|
||||
|
||||
items = frappe.parse_json(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 = []
|
||||
|
||||
mr = frappe.qb.DocType("Material Request")
|
||||
@@ -146,6 +172,7 @@ def get_linked_material_requests(items: str | list):
|
||||
mr_item.item_code,
|
||||
mr_item.name.as_("mr_item"),
|
||||
)
|
||||
.where(mr.name.isin(permitted_material_requests))
|
||||
.where(mr_item.item_code == item)
|
||||
.where(mr.material_request_type == "Purchase")
|
||||
.where(mr.per_ordered < 99.99)
|
||||
|
||||
@@ -258,6 +258,8 @@ class AccountsController(TransactionBase):
|
||||
if self.get("_action") and self._action != "update_after_submit":
|
||||
self.set_missing_values(for_validate=True)
|
||||
|
||||
self.validate_price_list()
|
||||
|
||||
if self.get("_action") == "submit":
|
||||
self.remove_bundle_for_non_stock_invoices()
|
||||
|
||||
@@ -346,6 +348,28 @@ class AccountsController(TransactionBase):
|
||||
self.set_default_letter_head()
|
||||
self.validate_company_in_accounting_dimension()
|
||||
|
||||
def validate_price_list(self):
|
||||
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
|
||||
price_list = self.get(price_list_field)
|
||||
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
|
||||
return
|
||||
|
||||
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
|
||||
if (
|
||||
self.get("is_return")
|
||||
and self.get("return_against")
|
||||
and price_list
|
||||
== frappe.db.get_value(
|
||||
self.doctype, {"name": self.return_against, "docstatus": 1}, price_list_field
|
||||
)
|
||||
):
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
)
|
||||
|
||||
def set_default_letter_head(self):
|
||||
if hasattr(self, "letter_head") and not self.letter_head:
|
||||
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")
|
||||
|
||||
@@ -194,7 +194,12 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
|
||||
if (doc.doctype == "Purchase Invoice" or doc.doctype == "Sales Invoice") and not doc.update_stock:
|
||||
fields = ["qty"]
|
||||
|
||||
if doc.doctype in ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]:
|
||||
tracks_accepted_rejected_split = doc.doctype in (
|
||||
"Purchase Receipt",
|
||||
"Subcontracting Receipt",
|
||||
) or (doc.doctype == "Purchase Invoice" and doc.update_stock)
|
||||
|
||||
if tracks_accepted_rejected_split:
|
||||
if not args.get("return_qty_from_rejected_warehouse"):
|
||||
fields.extend(["received_qty", "rejected_qty"])
|
||||
else:
|
||||
|
||||
@@ -1161,7 +1161,11 @@ def get_fg_reference_names(
|
||||
"Subcontracting Inward Order Item",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
filters={"parent": filters.get("parent"), "item_code": ("like", f"%{txt}%"), "docstatus": 1},
|
||||
filters={"parent": filters.get("parent"), "docstatus": 1},
|
||||
or_filters=[
|
||||
["name", "like", f"%{txt}%"],
|
||||
["item_code", "like", f"%{txt}%"],
|
||||
],
|
||||
fields=["name", "item_code", "delivery_warehouse"],
|
||||
as_list=True,
|
||||
order_by="idx",
|
||||
|
||||
@@ -2410,4 +2410,10 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
si.set_posting_time = 1
|
||||
si.posting_date = "2026-01-01"
|
||||
si.save()
|
||||
self.assertEqual(si.name, "SI-01-2026-00001")
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.set_posting_time = 1
|
||||
si.posting_date = "2026-01-15"
|
||||
si.save()
|
||||
self.assertEqual(si.name, "SI-01-2026-00002")
|
||||
|
||||
@@ -330,12 +330,14 @@ permission_query_conditions = {
|
||||
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
|
||||
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
|
||||
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
|
||||
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.get_inherited_permission_query_conditions",
|
||||
}
|
||||
|
||||
has_permission = {
|
||||
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
|
||||
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
|
||||
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
|
||||
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.has_inherited_permission",
|
||||
}
|
||||
|
||||
has_website_permission = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1040,7 +1040,7 @@ frappe.tour["BOM"] = [
|
||||
frappe.ui.form.on("BOM Secondary Item", {
|
||||
valuation_type(frm, cdt, cdn) {
|
||||
const row = locals[cdt][cdn];
|
||||
if (row.valuation_type !== "% of FG Cost") {
|
||||
if (row.valuation_type !== "% of Component Cost") {
|
||||
frappe.model.set_value(cdt, cdn, "cost_allocation_per", 0);
|
||||
}
|
||||
if (row.valuation_type === "Valuation Rate") {
|
||||
|
||||
@@ -529,13 +529,14 @@ class BOM(WebsiteGenerator):
|
||||
doc.set_status(save=True)
|
||||
|
||||
def set_fg_cost_allocation(self):
|
||||
self.cost_allocation_per = flt(self.cost_allocation_per)
|
||||
total_secondary_items_per = 0
|
||||
own_cost = 0
|
||||
for item in self.secondary_items:
|
||||
if item.valuation_type in ("Valuation Rate", "Manual"):
|
||||
item.cost_allocation_per = 0
|
||||
own_cost += flt(item.cost)
|
||||
total_secondary_items_per += item.cost_allocation_per
|
||||
total_secondary_items_per += flt(item.cost_allocation_per)
|
||||
|
||||
if self.cost_allocation_per == 100 and total_secondary_items_per:
|
||||
self.cost_allocation_per -= total_secondary_items_per
|
||||
@@ -551,9 +552,9 @@ class BOM(WebsiteGenerator):
|
||||
)
|
||||
|
||||
def validate_total_cost_allocation(self):
|
||||
total_cost_allocation_per = self.cost_allocation_per
|
||||
total_cost_allocation_per = flt(self.cost_allocation_per)
|
||||
for item in self.secondary_items:
|
||||
total_cost_allocation_per += item.cost_allocation_per
|
||||
total_cost_allocation_per += flt(item.cost_allocation_per)
|
||||
|
||||
if total_cost_allocation_per != 100:
|
||||
frappe.throw(_("Cost allocation between finished goods and secondary items should equal 100%"))
|
||||
@@ -901,6 +902,19 @@ class BOM(WebsiteGenerator):
|
||||
)
|
||||
)
|
||||
|
||||
bom_items = {self.item, *items}
|
||||
bom_items.update(d.item_code for d in self.get("secondary_items"))
|
||||
bom_items.update(d.finished_good for d in self.get("operations") if d.finished_good)
|
||||
|
||||
if disabled_items := frappe.db.get_all(
|
||||
"Item", filters={"item_code": ("in", list(bom_items)), "disabled": 1}, pluck="name"
|
||||
):
|
||||
frappe.throw(
|
||||
_("Disabled Item {0} cannot be used in BOMs.").format(
|
||||
", ".join(get_link_to_form("Item", item) for item in disabled_items)
|
||||
)
|
||||
)
|
||||
|
||||
def check_recursion(self):
|
||||
"""Check whether recursion occurs in any bom"""
|
||||
bom_list = self.traverse_tree()
|
||||
|
||||
@@ -269,7 +269,7 @@ class BOMCostingService:
|
||||
|
||||
def calculate_secondary_items_costs(self, save=False):
|
||||
"""Valuation Rate and Manual rows carry their own cost, deducted from the raw
|
||||
material cost; the % of FG Cost rows split the remainder by their percentage."""
|
||||
material cost; the % of Component Cost rows split the remainder by their percentage."""
|
||||
total_sm_cost = 0
|
||||
base_total_sm_cost = 0
|
||||
precision = self.doc.precision("raw_material_cost")
|
||||
@@ -279,7 +279,7 @@ class BOMCostingService:
|
||||
|
||||
for d in self.doc.get("secondary_items"):
|
||||
if d.valuation_type not in ("Valuation Rate", "Manual"):
|
||||
d.cost = flt(allocation_basis * (d.cost_allocation_per / 100), precision)
|
||||
d.cost = flt(allocation_basis * (flt(d.cost_allocation_per) / 100), precision)
|
||||
d.base_cost = flt(d.cost * self.doc.conversion_rate, precision)
|
||||
if save:
|
||||
d.db_update()
|
||||
|
||||
@@ -610,7 +610,7 @@ class TestBOM(ERPNextTestSuite):
|
||||
"secondary_item_type": "Additional Finished Good",
|
||||
"qty": 1,
|
||||
"cost_allocation_per": 10,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -645,7 +645,7 @@ class TestBOM(ERPNextTestSuite):
|
||||
"secondary_item_type": "Scrap",
|
||||
"qty": 1,
|
||||
"cost_allocation_per": 10,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, bom_doc.save)
|
||||
@@ -718,7 +718,7 @@ class TestBOM(ERPNextTestSuite):
|
||||
"secondary_item_type": "By-Product",
|
||||
"qty": 1,
|
||||
"cost_allocation_per": 10,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
},
|
||||
)
|
||||
bom_doc.save()
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.valuation_type == '% of FG Cost'",
|
||||
"depends_on": "eval:doc.valuation_type == '% of Component Cost'",
|
||||
"fieldname": "cost_allocation_per",
|
||||
"fieldtype": "Percent",
|
||||
"label": "Cost Allocation %",
|
||||
@@ -178,11 +178,11 @@
|
||||
},
|
||||
{
|
||||
"default": "Valuation Rate",
|
||||
"description": "Valuation Rate and Manual value this item on its own and deduct that cost from the raw material cost, like the pre-v16 scrap items. % of FG Cost allocates a percentage of the remaining raw material cost.",
|
||||
"description": "Valuation Rate and Manual value this item on its own and deduct that cost from the raw material cost, like the pre-v16 scrap items. % of Component Cost allocates a percentage of the remaining raw material cost.",
|
||||
"fieldname": "valuation_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Valuation Type",
|
||||
"options": "Valuation Rate\n% of FG Cost\nManual",
|
||||
"options": "Valuation Rate\n% of Component Cost\nManual",
|
||||
"reqd": 1,
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ class BOMSecondaryItem(Document):
|
||||
stock_qty: DF.Float
|
||||
stock_uom: DF.Link | None
|
||||
uom: DF.Link
|
||||
valuation_type: DF.Literal["Valuation Rate", "% of FG Cost", "Manual"]
|
||||
valuation_type: DF.Literal["Valuation Rate", "% of Component Cost", "Manual"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -102,6 +102,9 @@ def make_stock_entry(source_name: str, target_doc: str | dict | Document | None
|
||||
target.qty = pending_rm_qty
|
||||
|
||||
def set_missing_values(source, target):
|
||||
if not source.items:
|
||||
frappe.throw(_("This Job Card has no raw materials to transfer."))
|
||||
|
||||
if source.finished_good and not source.target_warehouse:
|
||||
frappe.throw(_("Please set the Target Warehouse in the Job Card"))
|
||||
|
||||
|
||||
@@ -1497,7 +1497,7 @@ class TestJobCard(ERPNextTestSuite):
|
||||
"qty": 1,
|
||||
"process_loss_per": 10,
|
||||
"cost_allocation_per": 5,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
"secondary_item_type": "Scrap",
|
||||
},
|
||||
)
|
||||
@@ -2956,7 +2956,7 @@ class TestJobCard(ERPNextTestSuite):
|
||||
"secondary_item_type": "Scrap",
|
||||
"qty": 1,
|
||||
"cost_allocation_per": cost_allocation_per,
|
||||
"valuation_type": "% of FG Cost" if cost_allocation_per else "Valuation Rate",
|
||||
"valuation_type": "% of Component Cost" if cost_allocation_per else "Valuation Rate",
|
||||
},
|
||||
)
|
||||
bom_doc.save()
|
||||
@@ -3012,7 +3012,7 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(rows[bom_links[0]].qty, 2)
|
||||
self.assertEqual(rows[bom_links[0]].valuation_type, "Valuation Rate")
|
||||
self.assertEqual(rows[bom_links[1]].qty, 3)
|
||||
self.assertEqual(rows[bom_links[1]].valuation_type, "% of FG Cost")
|
||||
self.assertEqual(rows[bom_links[1]].valuation_type, "% of Component Cost")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Manufacturing Settings", {"overproduction_percentage_for_work_order": 100}
|
||||
@@ -3251,6 +3251,98 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(s.additional_costs[2].amount, 480)
|
||||
self.assertEqual(s.additional_costs[3].amount, 480)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
|
||||
def test_stock_entry_needs_a_job_card_item_reference(self):
|
||||
create_bom_with_multiple_operations()
|
||||
work_order = make_wo_with_transfer_against_jc()
|
||||
job_card = frappe.db.get_value("Job Card", {"work_order": work_order.name})
|
||||
|
||||
stock_entry = frappe.new_doc("Stock Entry")
|
||||
stock_entry.job_card = job_card
|
||||
stock_entry.purpose = "Material Transfer for Manufacture"
|
||||
stock_entry.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"s_warehouse": "Stores - _TC",
|
||||
"qty": 1,
|
||||
"job_card_item": None,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"job card item reference is missing",
|
||||
stock_entry.validate_job_card_item,
|
||||
)
|
||||
|
||||
def test_stock_entry_finished_good_must_match_the_job_card(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
raw_material = make_item("_Test JC FG Check RM", {"is_stock_item": 1}).name
|
||||
finished_good = make_item("_Test JC FG Check FG", {"is_stock_item": 1}).name
|
||||
unrelated_item = make_item("_Test JC FG Check Other", {"is_stock_item": 1}).name
|
||||
|
||||
operation = {
|
||||
"operation": "_Test JC FG Check Op",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": finished_good,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation)
|
||||
make_operation(operation)
|
||||
|
||||
bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=finished_good,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
bom.append("items", {"item_code": raw_material, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("operations", operation)
|
||||
bom.insert()
|
||||
bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=finished_good,
|
||||
qty=1,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
job_card = frappe.db.get_value("Job Card", {"work_order": work_order.name})
|
||||
self.assertEqual(frappe.db.get_value("Job Card", job_card, "finished_good"), finished_good)
|
||||
|
||||
mismatched = frappe.new_doc("Stock Entry")
|
||||
mismatched.job_card = job_card
|
||||
mismatched.append("items", {"item_code": unrelated_item, "is_finished_item": 1, "qty": 1})
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
f"Finished Good must be {finished_good}",
|
||||
mismatched.validate_job_card_fg_item,
|
||||
)
|
||||
|
||||
matching = frappe.new_doc("Stock Entry")
|
||||
matching.job_card = job_card
|
||||
matching.append("items", {"item_code": finished_good, "is_finished_item": 1, "qty": 1})
|
||||
matching.validate_job_card_fg_item()
|
||||
|
||||
|
||||
def create_bom_with_multiple_operations():
|
||||
"Create a BOM with multiple operations and Material Transfer against Job Card"
|
||||
|
||||
@@ -57,20 +57,25 @@ class MaterialRequestService:
|
||||
"""Create Material Requests grouped by Sales Order and Material Request Type"""
|
||||
self.validate_mr_subcontracted()
|
||||
|
||||
if all(item.requested_qty == item.quantity for item in self.doc.mr_items):
|
||||
msgprint(_("All items are already requested"))
|
||||
return
|
||||
|
||||
material_request_map = {}
|
||||
material_request_list = []
|
||||
for item in self.doc.mr_items:
|
||||
if item.quantity == item.requested_qty:
|
||||
qty_to_request = flt(flt(item.quantity) - flt(item.requested_qty), item.precision("quantity"))
|
||||
if qty_to_request <= 0:
|
||||
continue
|
||||
self._add_item_to_material_request(item, material_request_map, material_request_list)
|
||||
self._add_item_to_material_request(
|
||||
item, qty_to_request, material_request_map, material_request_list
|
||||
)
|
||||
|
||||
if not material_request_list:
|
||||
msgprint(_("All items are already requested"))
|
||||
return
|
||||
|
||||
self._submit_material_requests(material_request_list)
|
||||
|
||||
def _add_item_to_material_request(self, item, material_request_map, material_request_list):
|
||||
def _add_item_to_material_request(
|
||||
self, item, qty_to_request, material_request_map, material_request_list
|
||||
):
|
||||
item_doc = frappe.get_cached_doc("Item", item.item_code)
|
||||
material_request_type = item.material_request_type or item_doc.default_material_request_type
|
||||
|
||||
@@ -81,7 +86,7 @@ class MaterialRequestService:
|
||||
material_request_list.append(material_request_map[key])
|
||||
|
||||
schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days))
|
||||
row = self._material_request_item(item, material_request_type, schedule_date)
|
||||
row = self._material_request_item(item, material_request_type, schedule_date, qty_to_request)
|
||||
material_request_map[key].append("items", row)
|
||||
|
||||
def _new_material_request(self, material_request_type):
|
||||
@@ -96,7 +101,7 @@ class MaterialRequestService:
|
||||
)
|
||||
return mr
|
||||
|
||||
def _material_request_item(self, item, material_request_type, schedule_date):
|
||||
def _material_request_item(self, item, material_request_type, schedule_date, qty_to_request):
|
||||
from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None
|
||||
# a group warehouse cannot receive stock; it must never reach a Material Request line
|
||||
if item.warehouse and frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
|
||||
@@ -111,7 +116,7 @@ class MaterialRequestService:
|
||||
return {
|
||||
"item_code": item.item_code,
|
||||
"from_warehouse": from_warehouse,
|
||||
"qty": item.quantity - item.requested_qty,
|
||||
"qty": qty_to_request,
|
||||
"uom": item.uom,
|
||||
"schedule_date": schedule_date,
|
||||
"warehouse": item.warehouse,
|
||||
|
||||
@@ -110,6 +110,23 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
pln = frappe.get_doc("Production Plan", pln.name)
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_material_request_skips_zero_qty_items(self):
|
||||
pln = create_production_plan(item_code="Test Production Item 1")
|
||||
zero_qty_item, requested_item = pln.mr_items
|
||||
zero_qty_item.quantity = "0"
|
||||
|
||||
pln.make_material_request()
|
||||
|
||||
material_request_items = frappe.get_all(
|
||||
"Material Request Item",
|
||||
filters={"production_plan": pln.name},
|
||||
fields=["item_code", "qty"],
|
||||
)
|
||||
self.assertEqual(
|
||||
material_request_items,
|
||||
[{"item_code": requested_item.item_code, "qty": requested_item.quantity}],
|
||||
)
|
||||
|
||||
def test_production_plan_start_date(self):
|
||||
"Test if Work Order has same Planned Start Date as Prod Plan."
|
||||
planned_date = add_to_date(date=None, days=3)
|
||||
@@ -4081,7 +4098,7 @@ def make_bom(**args):
|
||||
"stock_uom": item_doc.stock_uom,
|
||||
"qty": args.scrap_qty or 1,
|
||||
"cost_allocation_per": args.scrap_cost_allocation_per or 10,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
"process_loss_per": args.scrap_process_loss_per or 10,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5403,7 +5403,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
"item_name": scrap_item,
|
||||
"qty": 3,
|
||||
"cost_allocation_per": 25,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
"process_loss_per": 0,
|
||||
},
|
||||
)
|
||||
@@ -5452,7 +5452,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
"item_name": scrap_item,
|
||||
"qty": 3,
|
||||
"cost_allocation_per": 25,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
"process_loss_per": 0,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -139,6 +139,10 @@ frappe.ui.form.on("Work Order", {
|
||||
frm.fields_dict["secondary_items"].grid.wrapper?.find("> .control-label").text(label);
|
||||
},
|
||||
|
||||
company: function (frm) {
|
||||
erpnext.work_order.set_default_warehouse(frm);
|
||||
},
|
||||
|
||||
source_warehouse: function (frm) {
|
||||
let transaction_controller = new erpnext.TransactionController();
|
||||
transaction_controller.autofill_warehouse(
|
||||
@@ -1114,14 +1118,16 @@ erpnext.work_order = {
|
||||
},
|
||||
|
||||
set_default_warehouse: function (frm) {
|
||||
if (!(frm.doc.wip_warehouse || frm.doc.fg_warehouse)) {
|
||||
if (frm.doc.company && !(frm.doc.wip_warehouse || frm.doc.fg_warehouse)) {
|
||||
let company = frm.doc.company;
|
||||
frappe.call({
|
||||
method: "erpnext.manufacturing.doctype.work_order.work_order.get_default_warehouse",
|
||||
args: {
|
||||
company: frm.doc.company,
|
||||
company: company,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.exe) {
|
||||
// ignore stale responses if the company changed while the request was in flight
|
||||
if (!r.exe && frm.doc.company === company) {
|
||||
frm.set_value("wip_warehouse", r.message.wip_warehouse);
|
||||
frm.set_value("fg_warehouse", r.message.fg_warehouse);
|
||||
frm.set_value("scrap_warehouse", r.message.scrap_warehouse);
|
||||
|
||||
@@ -3,18 +3,95 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.manufacturing.doctype.job_card.mapper import make_stock_entry
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_bom
|
||||
from erpnext.manufacturing.doctype.workstation.workstation import (
|
||||
NotInWorkingHoursError,
|
||||
WorkstationHolidayError,
|
||||
check_if_within_operating_hours,
|
||||
get_raw_materials,
|
||||
update_job_card,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestWorkstation(ERPNextTestSuite):
|
||||
def test_get_raw_materials_without_items(self):
|
||||
for skip_transfer, backflush_from_wip in ((0, 0), (1, 0), (1, 1)):
|
||||
with self.subTest(skip_transfer=skip_transfer, backflush_from_wip=backflush_from_wip):
|
||||
job_card = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Job Card",
|
||||
"company": "_Test Company",
|
||||
"skip_material_transfer": skip_transfer,
|
||||
"backflush_from_wip_warehouse": backflush_from_wip,
|
||||
"wip_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
).insert(ignore_mandatory=True)
|
||||
|
||||
for method in (get_raw_materials, make_stock_entry):
|
||||
with self.subTest(method=method.__name__):
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, "This Job Card has no raw materials to transfer"
|
||||
):
|
||||
method(job_card.name)
|
||||
|
||||
job_card.reload()
|
||||
self.assertFalse(job_card.items)
|
||||
self.assertFalse(frappe.db.exists("Stock Entry", {"job_card": job_card.name}))
|
||||
|
||||
def test_get_raw_materials_availability(self):
|
||||
for skip_transfer, backflush_from_wip, transferred_qty in (
|
||||
(0, 0, 2),
|
||||
(0, 0, 5),
|
||||
(1, 0, 0),
|
||||
(1, 1, 0),
|
||||
):
|
||||
with self.subTest(
|
||||
skip_transfer=skip_transfer,
|
||||
backflush_from_wip=backflush_from_wip,
|
||||
transferred_qty=transferred_qty,
|
||||
):
|
||||
job_card = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Job Card",
|
||||
"company": "_Test Company",
|
||||
"skip_material_transfer": skip_transfer,
|
||||
"backflush_from_wip_warehouse": backflush_from_wip,
|
||||
"wip_warehouse": "_Test Warehouse 1 - _TC",
|
||||
"items": [
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"source_warehouse": "_Test Warehouse - _TC",
|
||||
"required_qty": 5,
|
||||
"transferred_qty": transferred_qty,
|
||||
},
|
||||
],
|
||||
}
|
||||
).insert(ignore_mandatory=True)
|
||||
|
||||
materials = get_raw_materials(job_card.name)
|
||||
|
||||
self.assertEqual(len(materials), 1)
|
||||
material = materials[0]
|
||||
warehouse = "_Test Warehouse 1 - _TC" if backflush_from_wip else "_Test Warehouse - _TC"
|
||||
stock_qty = (
|
||||
frappe.db.get_value(
|
||||
"Bin", {"item_code": "_Test Item", "warehouse": warehouse}, "actual_qty"
|
||||
)
|
||||
or 0
|
||||
)
|
||||
self.assertEqual(material.item_code, "_Test Item")
|
||||
self.assertEqual(material.required_qty, 5)
|
||||
self.assertEqual(material.transferred_qty, transferred_qty)
|
||||
self.assertEqual(material.warehouse, warehouse)
|
||||
self.assertEqual(material.stock_qty, stock_qty)
|
||||
self.assertEqual(
|
||||
material.material_availability_status,
|
||||
int(stock_qty >= 5) if skip_transfer else int(transferred_qty >= 5),
|
||||
)
|
||||
|
||||
def test_update_job_card_rejects_disallowed_method(self):
|
||||
# The whitelisted update_job_card endpoint must only run an allowlisted set of Job Card
|
||||
# methods. An arbitrary method name must be rejected (PermissionError) before the document
|
||||
|
||||
@@ -287,8 +287,8 @@ def get_raw_materials(job_card: str):
|
||||
filters={"name": job_card},
|
||||
)
|
||||
|
||||
if not raw_materials:
|
||||
return []
|
||||
if not raw_materials or not raw_materials[0].item_code:
|
||||
frappe.throw(_("This Job Card has no raw materials to transfer."))
|
||||
|
||||
for row in raw_materials:
|
||||
warehouse = row.source_warehouse
|
||||
|
||||
@@ -1113,7 +1113,7 @@ class MaterialRequirementsPlanningReport:
|
||||
args["to_date"] = add_days(from_date, -1)
|
||||
|
||||
if bucket_size == "Monthly":
|
||||
args["label"] = formatdate(from_date, "MMM YYYY")
|
||||
args["label"] = formatdate(args["from_date"], "MMM YYYY")
|
||||
else:
|
||||
if bucket_size == "Weekly":
|
||||
args["label"] = (
|
||||
|
||||
@@ -520,3 +520,6 @@ erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
|
||||
erpnext.patches.v16_0.set_secondary_item_valuation_type
|
||||
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
|
||||
erpnext.patches.v16_0.set_supplier_quotation_order_status
|
||||
erpnext.patches.v16_0.recalculate_holiday_list_totals
|
||||
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
|
||||
erpnext.patches.v16_0.rename_component_cost_valuation_type
|
||||
|
||||
@@ -8,10 +8,12 @@ def execute():
|
||||
"fieldname": "service_level_agreement",
|
||||
"fieldtype": "Link",
|
||||
"options": "Service Level Agreement",
|
||||
"link_filters": ("is", "not set"),
|
||||
},
|
||||
fields=["name", "dt"],
|
||||
fields=["name", "dt", "link_filters"],
|
||||
):
|
||||
if custom_field.link_filters:
|
||||
continue
|
||||
|
||||
link_filters = frappe.as_json(
|
||||
[["Service Level Agreement", "document_type", "=", custom_field.dt]], indent=None
|
||||
)
|
||||
|
||||
@@ -9,10 +9,12 @@ def execute():
|
||||
"fieldname": "service_level_agreement",
|
||||
"fieldtype": "Link",
|
||||
"options": "Service Level Agreement",
|
||||
"link_filters": ("is", "not set"),
|
||||
},
|
||||
fields=["name", "parent"],
|
||||
fields=["name", "parent", "link_filters"],
|
||||
):
|
||||
if docfield.link_filters:
|
||||
continue
|
||||
|
||||
link_filters = frappe.as_json(
|
||||
[["Service Level Agreement", "document_type", "=", docfield.parent]], indent=None
|
||||
)
|
||||
|
||||
18
erpnext/patches/v16_0/recalculate_holiday_list_totals.py
Normal file
18
erpnext/patches/v16_0/recalculate_holiday_list_totals.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import frappe
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
|
||||
|
||||
def execute():
|
||||
holiday_list = frappe.qb.DocType("Holiday List")
|
||||
holiday = frappe.qb.DocType("Holiday")
|
||||
total_holidays = (
|
||||
frappe.qb.from_(holiday)
|
||||
.select(Sum(Case().when(holiday.is_half_day == 1, 0.5).else_(1)))
|
||||
.where(
|
||||
(holiday.parent == holiday_list.name)
|
||||
& (holiday.parenttype == "Holiday List")
|
||||
& (holiday.parentfield == "holidays")
|
||||
)
|
||||
)
|
||||
frappe.qb.update(holiday_list).set(holiday_list.total_holidays, Coalesce(total_holidays, 0)).run()
|
||||
@@ -0,0 +1,32 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""Recalculate billing status of Delivery Notes left open by a return.
|
||||
|
||||
Returning the uninvoiced qty of a Delivery Note did not recalculate the original
|
||||
Delivery Note, so it stayed "To Bill" / "Partially Billed" with nothing left to invoice.
|
||||
"""
|
||||
dn = frappe.qb.DocType("Delivery Note")
|
||||
dn_item = frappe.qb.DocType("Delivery Note Item")
|
||||
|
||||
delivery_notes = (
|
||||
frappe.qb.from_(dn)
|
||||
.inner_join(dn_item)
|
||||
.on(dn_item.parent == dn.name)
|
||||
.select(dn.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(dn.docstatus == 1)
|
||||
& (dn.is_return == 0)
|
||||
& dn.status.isin(["To Bill", "Partially Billed"])
|
||||
& (dn_item.returned_qty > 0)
|
||||
)
|
||||
.run(pluck=True)
|
||||
)
|
||||
|
||||
for name in delivery_notes:
|
||||
doc = frappe.get_doc("Delivery Note", name)
|
||||
doc.update_billing_percentage(update_modified=False)
|
||||
doc.load_from_db()
|
||||
doc.set_status(update=True, update_modified=False)
|
||||
@@ -0,0 +1,15 @@
|
||||
import frappe
|
||||
|
||||
DOCTYPES = ("BOM Secondary Item", "Stock Entry Detail", "Subcontracting Receipt Item")
|
||||
|
||||
|
||||
def execute():
|
||||
"""Rename the `% of FG Cost` valuation type: the percentage is of the component cost."""
|
||||
for doctype in DOCTYPES:
|
||||
frappe.db.set_value(
|
||||
doctype,
|
||||
{"valuation_type": "% of FG Cost"},
|
||||
"valuation_type",
|
||||
"% of Component Cost",
|
||||
update_modified=False,
|
||||
)
|
||||
@@ -69,7 +69,7 @@ erpnext.buying = {
|
||||
if (this.frm.fields_dict.buying_price_list) {
|
||||
this.frm.set_query("buying_price_list", function () {
|
||||
return {
|
||||
filters: { buying: 1 },
|
||||
filters: { buying: 1, enabled: 1 },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -541,7 +541,7 @@ erpnext.buying.link_to_mrs = function (frm) {
|
||||
var item_length = frm.doc.items.length;
|
||||
for (let item of frm.doc.items) {
|
||||
var qty = item.qty;
|
||||
(r.message[0] || []).forEach(function (d) {
|
||||
(r.message || []).forEach(function (d) {
|
||||
if (
|
||||
d.qty > 0 &&
|
||||
qty > 0 &&
|
||||
|
||||
@@ -1150,11 +1150,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
if (this.frm.doc.set_posting_time) return;
|
||||
if (frappe.datetime.get_today() == this.frm.doc.posting_date) return;
|
||||
|
||||
let is_confirmation_reqd = await frappe.db.get_single_value(
|
||||
"Accounts Settings",
|
||||
"confirm_before_resetting_posting_date"
|
||||
const is_confirmation_reqd = await frappe.xcall(
|
||||
"erpnext.accounts.doctype.accounts_settings.accounts_settings.get_posting_date_confirmation"
|
||||
);
|
||||
|
||||
if (!is_confirmation_reqd) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -727,7 +727,7 @@
|
||||
<button class="btn btn-primary mes-btn-start" data-job-card="{{ frappe.utils.escape_html(slot.name) }}">
|
||||
{{ __("Start Job") }} →
|
||||
</button>
|
||||
{% } else { %}
|
||||
{% } else if (slot.materials && slot.materials.length) { %}
|
||||
<button class="btn btn-default mes-btn-transfer" data-job-card="{{ frappe.utils.escape_html(slot.name) }}">
|
||||
{{ __("Transfer Materials") }}
|
||||
</button>
|
||||
@@ -990,7 +990,7 @@
|
||||
<button class="btn btn-default btn-sm mes-btn-start" data-job-card="{{ frappe.utils.escape_html(jc.name) }}">
|
||||
{{ __("Start") }}
|
||||
</button>
|
||||
{% } else { %}
|
||||
{% } else if (jc.materials && jc.materials.length) { %}
|
||||
<button class="btn btn-default btn-sm mes-btn-transfer" data-job-card="{{ frappe.utils.escape_html(jc.name) }}">
|
||||
{{ __("Transfer") }}
|
||||
</button>
|
||||
|
||||
@@ -24,14 +24,14 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) {
|
||||
args = {
|
||||
party: frm.doc.customer || frm.doc.party_name,
|
||||
party_type: party_type,
|
||||
price_list: frm.doc.selling_price_list,
|
||||
price_list: frappe.defaults.get_default("selling_price_list"),
|
||||
};
|
||||
} else if (frm.doc.supplier) {
|
||||
args = {
|
||||
party: frm.doc.supplier,
|
||||
party_type: "Supplier",
|
||||
bill_date: frm.doc.bill_date,
|
||||
price_list: frm.doc.buying_price_list,
|
||||
price_list: frappe.defaults.get_default("buying_price_list"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ erpnext.sales_common = {
|
||||
|
||||
if (this.frm.fields_dict.selling_price_list) {
|
||||
this.frm.set_query("selling_price_list", function () {
|
||||
return { filters: { selling: 1 } };
|
||||
return { filters: { selling: 1, enabled: 1 } };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,20 +31,4 @@ frappe.query_reports["IRS 1099"] = {
|
||||
width: 80,
|
||||
},
|
||||
],
|
||||
|
||||
onload: function (query_report) {
|
||||
query_report.page.add_inner_button(__("Print IRS 1099 Forms"), () => {
|
||||
build_1099_print(query_report);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function build_1099_print(query_report) {
|
||||
let filters = JSON.stringify(query_report.get_values());
|
||||
let w = window.open(
|
||||
"/api/method/erpnext.regional.report.irs_1099.irs_1099.irs_1099_print?" +
|
||||
"&filters=" +
|
||||
encodeURIComponent(filters)
|
||||
);
|
||||
// w.print();
|
||||
}
|
||||
|
||||
@@ -83,46 +83,6 @@ def get_columns():
|
||||
]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def irs_1099_print(filters: str | dict):
|
||||
if not filters:
|
||||
frappe._dict(
|
||||
{
|
||||
"company": frappe.db.get_default("Company"),
|
||||
"fiscal_year": frappe.db.get_default("Fiscal Year"),
|
||||
}
|
||||
)
|
||||
else:
|
||||
filters = frappe._dict(frappe.parse_json(filters))
|
||||
|
||||
fiscal_year_doc = get_fiscal_year(fiscal_year=filters.fiscal_year, as_dict=True)
|
||||
fiscal_year = cstr(fiscal_year_doc.year_start_date.year)
|
||||
|
||||
company_address = get_payer_address_html(filters.company)
|
||||
company_tin = frappe.db.get_value("Company", filters.company, "tax_id")
|
||||
|
||||
columns, data = execute(filters)
|
||||
template = frappe.get_doc("Print Format", "IRS 1099 Form").html
|
||||
output = PdfWriter()
|
||||
|
||||
for row in data:
|
||||
row["fiscal_year"] = fiscal_year
|
||||
row["company"] = filters.company
|
||||
row["company_tin"] = company_tin
|
||||
row["payer_street_address"] = company_address
|
||||
row["recipient_street_address"], row["recipient_city_state"] = get_street_address_html(
|
||||
"Supplier", row.supplier
|
||||
)
|
||||
row["payments"] = fmt_money(row["payments"], precision=0, currency="USD")
|
||||
get_pdf(render_template(template, row), output=output if output else None)
|
||||
|
||||
frappe.local.response.filename = (
|
||||
f"{filters.fiscal_year} {filters.company} IRS 1099 Forms{IRS_1099_FORMS_FILE_EXTENSION}"
|
||||
)
|
||||
frappe.local.response.filecontent = read_multi_pdf(output)
|
||||
frappe.local.response.type = "download"
|
||||
|
||||
|
||||
def get_payer_address_html(company):
|
||||
address = frappe.qb.DocType("Address")
|
||||
address_list = (
|
||||
|
||||
@@ -558,11 +558,8 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
|
||||
|
||||
# if the current user does not have permissions to override credit limit,
|
||||
# prompt them to send out an email to the controller users
|
||||
frappe.msgprint(
|
||||
message,
|
||||
title=_("Credit Limit Crossed"),
|
||||
raise_exception=1,
|
||||
primary_action={
|
||||
primary_action = (
|
||||
{
|
||||
"label": "Send Email",
|
||||
"server_action": "erpnext.selling.doctype.customer.customer.send_emails",
|
||||
"hide_on_success": True,
|
||||
@@ -572,7 +569,16 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
|
||||
"credit_limit": credit_limit,
|
||||
"credit_controller_users_list": credit_controller_users,
|
||||
},
|
||||
},
|
||||
}
|
||||
if frappe.has_permission("Customer", ptype="email", doc=customer)
|
||||
else None
|
||||
)
|
||||
|
||||
frappe.msgprint(
|
||||
message,
|
||||
title=_("Credit Limit Crossed"),
|
||||
raise_exception=1,
|
||||
primary_action=primary_action,
|
||||
)
|
||||
|
||||
|
||||
@@ -580,6 +586,7 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
|
||||
def send_emails(
|
||||
customer: str, customer_outstanding: float, credit_limit: float, credit_controller_users_list: str | list
|
||||
):
|
||||
frappe.has_permission("Customer", ptype="email", doc=customer, throw=True)
|
||||
credit_controller_users_list = frappe.parse_json(credit_controller_users_list)
|
||||
subject = _("Credit limit reached for customer {0}").format(customer)
|
||||
message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format(
|
||||
|
||||
@@ -212,6 +212,7 @@ def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
|
||||
@frappe.whitelist()
|
||||
def send_proforma_email(proforma_name: str, recipients: str) -> None:
|
||||
proforma = frappe.get_doc("Proforma Invoice", proforma_name)
|
||||
proforma.check_permission("email")
|
||||
if proforma.docstatus != 1:
|
||||
frappe.throw(_("Only an issued Proforma Invoice can be emailed."))
|
||||
if not proforma.proforma_pdf:
|
||||
|
||||
@@ -10,7 +10,7 @@ import frappe.utils
|
||||
from frappe import _, qb
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Abs, Sum
|
||||
from frappe.query_builder.functions import Abs, IfNull, Round, Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form, getdate
|
||||
from pypika import Order
|
||||
|
||||
@@ -952,8 +952,26 @@ def get_stock_reservation_status():
|
||||
return frappe.get_single_value("Stock Settings", "enable_stock_reservation")
|
||||
|
||||
|
||||
def get_pending_qty_criterion(sales_order_item):
|
||||
"""Mirror the mapper's pending quantity check."""
|
||||
invoice_item = qb.DocType("Sales Invoice Item")
|
||||
billed_qty = (
|
||||
qb.from_(invoice_item)
|
||||
.select(IfNull(Sum(invoice_item.qty), 0))
|
||||
.where((invoice_item.docstatus == 1) & (invoice_item.so_detail == sales_order_item.name))
|
||||
)
|
||||
|
||||
qty_precision = frappe.get_precision("Sales Order Item", "qty")
|
||||
has_unbilled_ordered_qty = Round(sales_order_item.qty - billed_qty, qty_precision) > 0
|
||||
has_unbilled_delivered_qty = (
|
||||
Round(sales_order_item.qty - sales_order_item.returned_qty - billed_qty, qty_precision) > 0
|
||||
) | (Round(sales_order_item.delivered_qty - billed_qty, qty_precision) > 0)
|
||||
|
||||
return has_unbilled_ordered_qty & has_unbilled_delivered_qty
|
||||
|
||||
|
||||
def get_potentially_billable_item_criterion(sales_order, sales_order_item, item):
|
||||
"""Return the amount check for UI candidates. The mapper checks pending quantity."""
|
||||
"""Return the row level checks the Sales Invoice mapper applies."""
|
||||
global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance"))
|
||||
allowance = (
|
||||
Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance)
|
||||
@@ -963,11 +981,12 @@ def get_potentially_billable_item_criterion(sales_order, sales_order_item, item)
|
||||
Abs(sales_order_item.billed_amt) < Abs(sales_order_item.amount) * (1 + allowance / 100)
|
||||
)
|
||||
is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0)
|
||||
|
||||
return (sales_order_item.closed == 0) & (
|
||||
is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom)
|
||||
is_billable_row = (
|
||||
(sales_order_item.qty != 0) & has_amount_headroom & get_pending_qty_criterion(sales_order_item)
|
||||
)
|
||||
|
||||
return (sales_order_item.closed == 0) & (is_unit_price_row | is_billable_row)
|
||||
|
||||
|
||||
def has_potentially_billable_items(sales_order: str) -> bool:
|
||||
"""Return whether a Sales Order has an item with billing amount headroom."""
|
||||
@@ -1016,7 +1035,7 @@ def get_potentially_billable_sales_orders(
|
||||
|
||||
query = frappe.qb.get_query(
|
||||
so,
|
||||
fields=[so.name, so.customer, so.transaction_date],
|
||||
fields=[so.name, so.customer, so.transaction_date, so.creation],
|
||||
filters=filters,
|
||||
or_filters=or_filters,
|
||||
ignore_permissions=False,
|
||||
@@ -1029,7 +1048,8 @@ def get_potentially_billable_sales_orders(
|
||||
.on(item.name == so_item.item_code)
|
||||
.where(get_potentially_billable_item_criterion(so, so_item, item))
|
||||
.distinct()
|
||||
.orderby(so.transaction_date, order=Order.desc)
|
||||
.orderby(so.transaction_date, order=Order.asc)
|
||||
.orderby(so.creation, order=Order.asc)
|
||||
.limit(cint(page_len))
|
||||
.offset(cint(start))
|
||||
.run(as_dict=True)
|
||||
|
||||
@@ -440,6 +440,48 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(len(make_sales_invoice(so.name).items), 0)
|
||||
|
||||
def test_fully_billed_order_is_not_offered_within_billing_allowance(self):
|
||||
item = make_item(
|
||||
"_Test Fully Billed Allowance Item",
|
||||
{"is_stock_item": 1, "over_billing_allowance": 0},
|
||||
).name
|
||||
so = make_sales_order(item_code=item, qty=10, rate=100)
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
so.load_from_db()
|
||||
self.assertEqual(flt(so.per_billed), 100)
|
||||
|
||||
filters = {"docstatus": 1, "company": so.company, "customer": so.customer}
|
||||
|
||||
with change_settings("Accounts Settings", {"over_billing_allowance": 100}):
|
||||
self.assertFalse(has_potentially_billable_items(so.name))
|
||||
|
||||
rows = get_potentially_billable_sales_orders("Sales Order", "", "name", 0, 50, filters)
|
||||
self.assertNotIn(so.name, [row.name for row in rows])
|
||||
|
||||
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
|
||||
|
||||
def test_order_with_sub_precision_pending_qty_is_not_offered(self):
|
||||
item = make_item("_Test Sub Precision Qty Item", {"is_stock_item": 1}).name
|
||||
so = make_sales_order(item_code=item, qty=10, rate=100)
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
si.get("items")[0].rate = 90
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
qty_precision = frappe.get_precision("Sales Order Item", "qty")
|
||||
billed_qty = 10 - 10 ** -(qty_precision + 1)
|
||||
frappe.db.set_value(
|
||||
"Sales Invoice Item", si.get("items")[0].name, "qty", billed_qty, update_modified=False
|
||||
)
|
||||
|
||||
self.assertFalse(has_potentially_billable_items(so.name))
|
||||
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
|
||||
|
||||
def test_make_sales_invoice_after_return_and_redelivery(self):
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
function update_total_holidays(frm) {
|
||||
let total_holidays = 0;
|
||||
for (const holiday of frm.doc.holidays || []) {
|
||||
total_holidays += holiday.is_half_day ? 0.5 : 1;
|
||||
}
|
||||
frm.doc.total_holidays = total_holidays;
|
||||
frm.refresh_field("total_holidays");
|
||||
}
|
||||
|
||||
frappe.ui.form.on("Holiday List", {
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.holidays) {
|
||||
frm.set_value("total_holidays", frm.doc.holidays.length);
|
||||
}
|
||||
update_total_holidays(frm);
|
||||
|
||||
frm.call("get_supported_countries").then((r) => {
|
||||
frm.subdivisions_by_country = r.message.subdivisions_by_country;
|
||||
@@ -43,6 +50,18 @@ frappe.ui.form.on("Holiday List", {
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Holiday", {
|
||||
holidays_add: function (frm) {
|
||||
update_total_holidays(frm);
|
||||
},
|
||||
holidays_remove: function (frm) {
|
||||
update_total_holidays(frm);
|
||||
},
|
||||
is_half_day: function (frm) {
|
||||
update_total_holidays(frm);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.tour["Holiday List"] = [
|
||||
{
|
||||
fieldname: "holiday_list_name",
|
||||
|
||||
@@ -58,9 +58,10 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "total_holidays",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Total Holidays",
|
||||
"precision": "1",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ from datetime import date
|
||||
import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import DateTimeLikeObject, formatdate, getdate, today
|
||||
from frappe.utils import DateTimeLikeObject, cint, formatdate, getdate, today
|
||||
|
||||
|
||||
class OverlapError(frappe.ValidationError):
|
||||
@@ -34,7 +34,7 @@ class HolidayList(Document):
|
||||
is_half_day: DF.Check
|
||||
subdivision: DF.Autocomplete | None
|
||||
to_date: DF.Date
|
||||
total_holidays: DF.Int
|
||||
total_holidays: DF.Float
|
||||
weekly_off: DF.Literal[
|
||||
"", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
|
||||
]
|
||||
@@ -42,10 +42,13 @@ class HolidayList(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_days()
|
||||
self.total_holidays = len(self.holidays)
|
||||
self.update_total_holidays()
|
||||
self.validate_duplicate_date()
|
||||
self.sort_holidays()
|
||||
|
||||
def update_total_holidays(self):
|
||||
self.total_holidays = sum(0.5 if cint(holiday.is_half_day) else 1 for holiday in self.holidays)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_weekly_off_dates(self):
|
||||
if not self.weekly_off:
|
||||
@@ -67,6 +70,8 @@ class HolidayList(Document):
|
||||
},
|
||||
)
|
||||
|
||||
self.update_total_holidays()
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_supported_countries(self):
|
||||
from holidays.utils import list_supported_countries
|
||||
@@ -108,6 +113,8 @@ class HolidayList(Document):
|
||||
"holidays", {"description": holiday_name, "holiday_date": holiday_date, "weekly_off": 0}
|
||||
)
|
||||
|
||||
self.update_total_holidays()
|
||||
|
||||
def sort_holidays(self):
|
||||
self.holidays.sort(key=lambda x: (x.weekly_off, getdate(x.holiday_date)))
|
||||
for i in range(len(self.holidays)):
|
||||
@@ -153,6 +160,7 @@ class HolidayList(Document):
|
||||
@frappe.whitelist()
|
||||
def clear_table(self):
|
||||
self.set("holidays", [])
|
||||
self.update_total_holidays()
|
||||
|
||||
def validate_duplicate_date(self):
|
||||
unique_dates = []
|
||||
|
||||
@@ -4,7 +4,7 @@ from contextlib import contextmanager
|
||||
from datetime import date, timedelta
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
from frappe.utils import get_datetime, getdate
|
||||
|
||||
from erpnext.setup.doctype.holiday_list.holiday_list import local_country_name
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -45,6 +45,94 @@ class TestHolidayList(ERPNextTestSuite):
|
||||
self.assertIn(date(2023, 2, 26), holidays)
|
||||
self.assertNotIn(date(2023, 3, 5), holidays)
|
||||
|
||||
def test_total_holidays_includes_half_days(self):
|
||||
holiday_list = make_holiday_list(
|
||||
"test_half_day_holiday_list",
|
||||
from_date="2023-01-01",
|
||||
to_date="2023-01-03",
|
||||
holiday_dates=[
|
||||
{"holiday_date": "2023-01-01", "description": "Full-day holiday"},
|
||||
{
|
||||
"holiday_date": "2023-01-02",
|
||||
"description": "Half-day holiday",
|
||||
"is_half_day": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(holiday_list.total_holidays, 1.5)
|
||||
self.assertEqual(frappe.db.get_value("Holiday List", holiday_list.name, "total_holidays"), 1.5)
|
||||
|
||||
def test_weekly_off_updates_total_without_saving(self):
|
||||
holiday_list = frappe.new_doc("Holiday List")
|
||||
holiday_list.from_date = "2023-01-01"
|
||||
holiday_list.to_date = "2023-01-14"
|
||||
holiday_list.weekly_off = "Saturday"
|
||||
holiday_list.is_half_day = 1
|
||||
holiday_list.append("holidays", {"holiday_date": "2023-01-01", "description": "Full day"})
|
||||
|
||||
holiday_list.get_weekly_off_dates()
|
||||
self.assertEqual(len(holiday_list.holidays), 3)
|
||||
self.assertEqual(holiday_list.total_holidays, 2)
|
||||
|
||||
holiday_list.get_weekly_off_dates()
|
||||
self.assertEqual(len(holiday_list.holidays), 3)
|
||||
self.assertEqual(holiday_list.total_holidays, 2)
|
||||
|
||||
holiday_list.clear_table()
|
||||
self.assertEqual(holiday_list.holidays, [])
|
||||
self.assertEqual(holiday_list.total_holidays, 0)
|
||||
|
||||
def test_local_holidays_updates_total_without_saving(self):
|
||||
holiday_list = frappe.new_doc("Holiday List")
|
||||
holiday_list.from_date = "2023-01-01"
|
||||
holiday_list.to_date = "2023-01-02"
|
||||
holiday_list.country = "DE"
|
||||
holiday_list.append(
|
||||
"holidays", {"holiday_date": "2023-01-02", "description": "Half day", "is_half_day": 1}
|
||||
)
|
||||
|
||||
holiday_list.get_local_holidays()
|
||||
self.assertEqual(len(holiday_list.holidays), 2)
|
||||
self.assertEqual(holiday_list.total_holidays, 1.5)
|
||||
|
||||
holiday_list.get_local_holidays()
|
||||
self.assertEqual(len(holiday_list.holidays), 2)
|
||||
self.assertEqual(holiday_list.total_holidays, 1.5)
|
||||
|
||||
def test_recalculate_existing_holiday_list_totals(self):
|
||||
from erpnext.patches.v16_0.recalculate_holiday_list_totals import execute
|
||||
|
||||
cases = (("mixed", [0, 1], 1.5), ("half", [1, 1, 1], 1.5), ("full", [0, 0], 2), ("empty", [], 0))
|
||||
holiday_lists = []
|
||||
for name, half_days, expected in cases:
|
||||
holiday_list = make_holiday_list(
|
||||
f"test_backfill_holidays_{name}",
|
||||
from_date="2023-01-01",
|
||||
to_date="2023-01-03",
|
||||
holiday_dates=[
|
||||
{
|
||||
"holiday_date": date(2023, 1, idx),
|
||||
"description": "Test holiday",
|
||||
"is_half_day": is_half_day,
|
||||
}
|
||||
for idx, is_half_day in enumerate(half_days, start=1)
|
||||
],
|
||||
)
|
||||
# Simulate totals persisted by the old controller, including a stale empty list.
|
||||
holiday_list.db_set("total_holidays", len(half_days) or 1, update_modified=False)
|
||||
holiday_lists.append((holiday_list, expected))
|
||||
|
||||
for _ in range(2):
|
||||
execute()
|
||||
for holiday_list, expected in holiday_lists:
|
||||
with self.subTest(holiday_list=holiday_list.name):
|
||||
total, modified = frappe.db.get_value(
|
||||
"Holiday List", holiday_list.name, ["total_holidays", "modified"]
|
||||
)
|
||||
self.assertEqual(total, expected)
|
||||
self.assertEqual(modified, get_datetime(holiday_list.modified))
|
||||
|
||||
def test_local_holidays(self):
|
||||
holiday_list = frappe.new_doc("Holiday List")
|
||||
holiday_list.from_date = "2022-01-01"
|
||||
|
||||
@@ -7,6 +7,7 @@ from frappe.defaults import get_user_default
|
||||
from frappe.utils import cint
|
||||
|
||||
import erpnext.accounts.utils
|
||||
from erpnext.stock.doctype.price_list.price_list import is_price_list_enabled
|
||||
|
||||
|
||||
def boot_session(bootinfo):
|
||||
@@ -28,6 +29,8 @@ def boot_session(bootinfo):
|
||||
frappe.get_single_value("Accounts Settings", "disable_include_dimensions")
|
||||
)
|
||||
|
||||
remove_disabled_price_list_defaults(bootinfo)
|
||||
|
||||
bootinfo.sysdefaults.quotation_valid_till = cint(
|
||||
frappe.db.get_single_value("CRM Settings", "default_valid_till")
|
||||
)
|
||||
@@ -81,6 +84,18 @@ def boot_session(bootinfo):
|
||||
bootinfo.sysdefaults.repost_allowed_doctypes = frappe.get_hooks("repost_allowed_doctypes")
|
||||
|
||||
|
||||
def remove_disabled_price_list_defaults(bootinfo):
|
||||
user_defaults = (bootinfo.user or {}).get("defaults") or {}
|
||||
|
||||
for key in ("selling_price_list", "buying_price_list"):
|
||||
price_list = bootinfo.sysdefaults.get(key) or user_defaults.get(key)
|
||||
if not isinstance(price_list, str) or is_price_list_enabled(price_list):
|
||||
continue
|
||||
|
||||
bootinfo.sysdefaults.pop(key, None)
|
||||
user_defaults.pop(key, None)
|
||||
|
||||
|
||||
def update_page_info(bootinfo):
|
||||
bootinfo.page_info.update(
|
||||
{
|
||||
|
||||
@@ -20,3 +20,25 @@ class TestBoot(ERPNextTestSuite):
|
||||
|
||||
company_docs = [d for d in bootinfo.docs if d.get("doctype") == ":Company"]
|
||||
self.assertTrue(any(d.get("name") == "_Test Company" for d in company_docs))
|
||||
|
||||
def test_boot_session_drops_disabled_price_list_default(self):
|
||||
from erpnext.startup.boot import boot_session
|
||||
|
||||
price_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": 1,
|
||||
"enabled": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
bootinfo = frappe._dict(
|
||||
sysdefaults=frappe._dict(selling_price_list=price_list.name),
|
||||
page_info=frappe._dict(),
|
||||
docs=[],
|
||||
)
|
||||
boot_session(bootinfo)
|
||||
|
||||
self.assertIsNone(bootinfo.sysdefaults.get("selling_price_list"))
|
||||
|
||||
@@ -57,12 +57,8 @@ def get_warehouse_account(warehouse, warehouse_account=None, *, raise_error=True
|
||||
account = warehouse.account
|
||||
if not account and warehouse.parent_warehouse:
|
||||
if warehouse_account:
|
||||
if warehouse_account.get(warehouse.parent_warehouse):
|
||||
account = warehouse_account.get(warehouse.parent_warehouse).account
|
||||
else:
|
||||
from frappe.utils.nestedset import rebuild_tree
|
||||
|
||||
rebuild_tree("Warehouse")
|
||||
if parent := warehouse_account.get(warehouse.parent_warehouse):
|
||||
account = parent.account
|
||||
else:
|
||||
account = frappe.get_all(
|
||||
"Warehouse",
|
||||
|
||||
@@ -11,6 +11,8 @@ from pypika.terms import Bracket, ExistsCriterion
|
||||
|
||||
RESTRICTABLE_MASTER_DOCTYPES = ("Item", "Customer", "Supplier")
|
||||
|
||||
RESTRICTION_INHERITED_FROM = {"Item Price": ("Item", "item_code")}
|
||||
|
||||
COMPANY_RESTRICTION_EXEMPT_DOCTYPES = frozenset(
|
||||
{
|
||||
"Asset",
|
||||
@@ -71,6 +73,25 @@ def get_permission_query_conditions(user, doctype=None):
|
||||
return get_restriction_criterion(doctype, allowed_companies)
|
||||
|
||||
|
||||
def get_inherited_permission_query_conditions(user, doctype=None):
|
||||
if not (inherited := RESTRICTION_INHERITED_FROM.get(doctype)):
|
||||
return None
|
||||
|
||||
master_doctype, fieldname = inherited
|
||||
allowed_companies = get_allowed_companies(user, master_doctype)
|
||||
if not allowed_companies:
|
||||
return None
|
||||
|
||||
child = frappe.qb.DocType(doctype)
|
||||
master = frappe.qb.DocType(master_doctype)
|
||||
allowed_masters = (
|
||||
frappe.qb.from_(master)
|
||||
.select(master.name)
|
||||
.where(get_restriction_criterion(master_doctype, allowed_companies))
|
||||
)
|
||||
return child[fieldname].isin(allowed_masters)
|
||||
|
||||
|
||||
def get_restriction_criterion(doctype, companies):
|
||||
parent = frappe.qb.DocType(doctype)
|
||||
restriction = frappe.qb.DocType("Company Restriction")
|
||||
@@ -98,6 +119,17 @@ def has_permission(doc, ptype=None, user=None):
|
||||
return any(row.company in allowed_companies for row in doc.get("allowed_companies") or [])
|
||||
|
||||
|
||||
def has_inherited_permission(doc, ptype=None, user=None):
|
||||
if not (inherited := RESTRICTION_INHERITED_FROM.get(doc.doctype)):
|
||||
return True
|
||||
|
||||
master_doctype, fieldname = inherited
|
||||
if not (master_name := doc.get(fieldname)):
|
||||
return True
|
||||
|
||||
return has_permission(frappe.get_cached_doc(master_doctype, master_name), ptype, user)
|
||||
|
||||
|
||||
def validate_allowed_companies(doc, method=None):
|
||||
if not doc.get("restrict_to_companies"):
|
||||
doc.set("allowed_companies", [])
|
||||
|
||||
@@ -98,15 +98,7 @@ class TestCompanyRestriction(ERPNextTestSuite):
|
||||
def test_unrestricted_party_ignores_company_permission(self):
|
||||
customer = make_customer("_Test Party Details Company Permission Customer")
|
||||
user = self.make_user_with_roles("test_party_details_company@example.com", ["Sales User"])
|
||||
permission = {
|
||||
"user": user,
|
||||
"allow": "Company",
|
||||
"for_value": "_Test Company 1",
|
||||
"apply_to_all_doctypes": 1,
|
||||
}
|
||||
if not frappe.db.exists("User Permission", permission):
|
||||
frappe.get_doc({"doctype": "User Permission", **permission}).insert(ignore_permissions=True)
|
||||
frappe.clear_cache(user=user)
|
||||
self.allow_company(user, "_Test Company 1")
|
||||
|
||||
with self.set_user(user):
|
||||
results = party_query(
|
||||
@@ -155,6 +147,61 @@ class TestCompanyRestriction(ERPNextTestSuite):
|
||||
stock_entry.reload()
|
||||
stock_entry.cancel()
|
||||
|
||||
def allow_company(self, user, company):
|
||||
permission = {
|
||||
"user": user,
|
||||
"allow": "Company",
|
||||
"for_value": company,
|
||||
"apply_to_all_doctypes": 1,
|
||||
}
|
||||
if not frappe.db.exists("User Permission", permission):
|
||||
frappe.get_doc({"doctype": "User Permission", **permission}).insert(ignore_permissions=True)
|
||||
frappe.clear_cache(user=user)
|
||||
|
||||
def make_item_price(self, item_code):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Price",
|
||||
"price_list": "_Test Price List",
|
||||
"item_code": item_code,
|
||||
"price_list_rate": 100,
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
def test_item_price_inherits_item_company_restriction(self):
|
||||
restricted = make_item()
|
||||
allowed = make_item()
|
||||
self.restrict_to_companies("Item", restricted.name, ["_Test Company 1"])
|
||||
prices = {item.name: self.make_item_price(item.name) for item in (restricted, allowed)}
|
||||
|
||||
user = self.make_user_with_roles("test_item_price_restriction@example.com", ["Sales Master Manager"])
|
||||
self.allow_company(user, "_Test Company")
|
||||
|
||||
with self.set_user(user):
|
||||
visible = frappe.get_list(
|
||||
"Item Price",
|
||||
filters={"item_code": ("in", [restricted.name, allowed.name])},
|
||||
pluck="item_code",
|
||||
)
|
||||
self.assertEqual(visible, [allowed.name])
|
||||
|
||||
self.assertFalse(frappe.has_permission("Item Price", doc=prices[restricted.name]))
|
||||
self.assertTrue(frappe.has_permission("Item Price", doc=prices[allowed.name]))
|
||||
|
||||
def test_item_price_is_visible_without_company_permission(self):
|
||||
restricted = make_item()
|
||||
self.restrict_to_companies("Item", restricted.name, ["_Test Company 1"])
|
||||
price = self.make_item_price(restricted.name)
|
||||
|
||||
user = self.make_user_with_roles("test_item_price_unrestricted@example.com", ["Sales Master Manager"])
|
||||
|
||||
with self.set_user(user):
|
||||
self.assertTrue(frappe.has_permission("Item Price", doc=price))
|
||||
|
||||
def make_user_with_roles(self, email, roles):
|
||||
if not frappe.db.exists("User", email):
|
||||
frappe.get_doc(
|
||||
|
||||
@@ -29,6 +29,9 @@ class BillingStatusService:
|
||||
def update_billing_status(self, update_modified: bool = True) -> None:
|
||||
doc = self.doc
|
||||
updated_delivery_notes = [doc.name]
|
||||
if doc.is_return and doc.return_against:
|
||||
updated_delivery_notes.append(doc.return_against)
|
||||
|
||||
for d in doc.get("items"):
|
||||
if d.si_detail and not d.so_detail:
|
||||
d.db_set("billed_amt", d.amount, update_modified=update_modified)
|
||||
@@ -37,7 +40,8 @@ class BillingStatusService:
|
||||
|
||||
for dn in set(updated_delivery_notes):
|
||||
dn_doc = doc if (dn == doc.name) else frappe.get_lazy_doc("Delivery Note", dn)
|
||||
dn_doc.update_billing_percentage(update_modified=update_modified)
|
||||
update_dn_modified = update_modified and dn != doc.return_against
|
||||
dn_doc.update_billing_percentage(update_modified=update_dn_modified)
|
||||
|
||||
doc.load_from_db()
|
||||
|
||||
|
||||
@@ -1060,6 +1060,117 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
self.assertEqual(dn.status, "Completed")
|
||||
|
||||
def test_dn_is_completed_when_unbilled_item_is_returned(self):
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
|
||||
|
||||
make_stock_entry(target="_Test Warehouse - _TC", qty=1, basic_rate=100)
|
||||
make_stock_entry(item_code="_Test Item 2", target="_Test Warehouse - _TC", qty=1, basic_rate=100)
|
||||
|
||||
dn = create_delivery_note(do_not_submit=True)
|
||||
dn.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item 2",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"conversion_factor": 1,
|
||||
"allow_zero_valuation_rate": 1,
|
||||
"expense_account": "Cost of Goods Sold - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
dn.submit()
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.set("items", [item for item in si.items if item.item_code == "_Test Item"])
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
dn.reload()
|
||||
self.assertEqual(dn.per_billed, 50)
|
||||
self.assertEqual(dn.status, "Partially Billed")
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.set("items", [item for item in return_dn.items if item.item_code == "_Test Item 2"])
|
||||
return_dn.insert()
|
||||
# Mimic the submit request, which reconstructs the document from client data.
|
||||
return_dn = frappe.get_doc(return_dn.as_dict())
|
||||
return_dn.submit()
|
||||
|
||||
dn.reload()
|
||||
self.assertEqual(dn.items[1].returned_qty, 1)
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
self.assertEqual(dn.status, "Completed")
|
||||
|
||||
return_dn.cancel()
|
||||
|
||||
dn.reload()
|
||||
self.assertEqual(dn.items[1].returned_qty, 0)
|
||||
self.assertEqual(dn.per_billed, 50)
|
||||
self.assertEqual(dn.status, "Partially Billed")
|
||||
|
||||
def test_billing_status_repair_patch(self):
|
||||
"""Returns submitted before #58869 left the original Delivery Note's per_billed stale.
|
||||
|
||||
The repair patch recalculates such notes: a directly invoiced one whose remaining
|
||||
qty was returned becomes Completed, an uninvoiced Sales Order linked one goes back
|
||||
to To Bill.
|
||||
"""
|
||||
from erpnext.patches.v16_0 import recalculate_returned_delivery_note_billing_status as patch
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
|
||||
|
||||
# Delivery Note invoiced for 2 of 5 qty, the remaining 3 returned -> fully billed
|
||||
make_stock_entry(target="_Test Warehouse - _TC", qty=5, basic_rate=100)
|
||||
dn = create_delivery_note(qty=5)
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.items[0].qty = 2
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
dn_return = make_sales_return(dn.name)
|
||||
dn_return.items[0].qty = -3
|
||||
dn_return.insert()
|
||||
# Mimic the submit request, which reconstructs the document from client data.
|
||||
frappe.get_doc(dn_return.as_dict()).submit()
|
||||
|
||||
dn.load_from_db()
|
||||
self.assertEqual(dn.items[0].returned_qty, 3)
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
|
||||
# Sales Order linked Delivery Note, nothing invoiced, partly returned -> unbilled
|
||||
so = make_sales_order(qty=10)
|
||||
so_dn = create_dn_against_so(so.name, delivered_qty=5)
|
||||
|
||||
so_dn_return = make_sales_return(so_dn.name)
|
||||
so_dn_return.items[0].qty = -2
|
||||
so_dn_return.insert()
|
||||
frappe.get_doc(so_dn_return.as_dict()).submit()
|
||||
|
||||
so_dn.load_from_db()
|
||||
self.assertEqual(so_dn.items[0].returned_qty, 2)
|
||||
self.assertEqual(so_dn.per_billed, 0)
|
||||
|
||||
# Mimic the state left behind by a return submitted before the fix
|
||||
for name, per_billed in ((dn.name, 40), (so_dn.name, 50)):
|
||||
frappe.db.set_value(
|
||||
"Delivery Note",
|
||||
name,
|
||||
{"per_billed": per_billed, "status": "Partially Billed"},
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
patch.execute()
|
||||
|
||||
dn.load_from_db()
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
self.assertEqual(dn.status, "Completed")
|
||||
|
||||
so_dn.load_from_db()
|
||||
self.assertEqual(so_dn.per_billed, 0)
|
||||
self.assertEqual(so_dn.status, "To Bill")
|
||||
|
||||
def test_dn_billing_status_case2(self):
|
||||
# SO -> SI and SO -> DN1, DN2
|
||||
from erpnext.selling.doctype.sales_order.mapper import (
|
||||
|
||||
@@ -54,6 +54,11 @@ class DeliveryTrip(Document):
|
||||
self.update_status()
|
||||
self.update_delivery_notes(delete=True)
|
||||
|
||||
def after_mapping(self, source_doc):
|
||||
for stop in self.delivery_stops[:]:
|
||||
if not any(stop.get(df.fieldname) for df in stop.meta.fields):
|
||||
self.remove(stop)
|
||||
|
||||
def validate(self):
|
||||
if self._action == "submit" and not self.driver:
|
||||
frappe.throw(_("A driver must be set to submit."))
|
||||
@@ -80,7 +85,7 @@ class DeliveryTrip(Document):
|
||||
|
||||
def validate_stop_addresses(self):
|
||||
for stop in self.delivery_stops:
|
||||
if not stop.customer_address:
|
||||
if stop.address and not stop.customer_address:
|
||||
stop.customer_address = get_address_display(frappe.get_doc("Address", stop.address).as_dict())
|
||||
|
||||
def validate_delivery_note_not_draft(self):
|
||||
|
||||
@@ -6,6 +6,7 @@ import frappe
|
||||
from frappe.utils import add_days, flt, now_datetime, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_delivery_trip
|
||||
from erpnext.stock.doctype.delivery_trip.delivery_trip import (
|
||||
get_contact_and_address,
|
||||
get_default_contact,
|
||||
@@ -174,6 +175,32 @@ class TestDeliveryTrip(ERPNextTestSuite):
|
||||
self.assertEqual(result.parent, orphan_parent)
|
||||
self.assertIsNone(result.is_primary_contact)
|
||||
|
||||
def map_delivery_note_onto_trip(self, existing_stop):
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
delivery_note = create_delivery_note()
|
||||
trip = frappe.new_doc("Delivery Trip")
|
||||
trip.append("delivery_stops", existing_stop)
|
||||
|
||||
return delivery_note, make_delivery_trip(delivery_note.name, trip)
|
||||
|
||||
def test_mapping_drops_placeholder_stop(self):
|
||||
delivery_note, trip = self.map_delivery_note_onto_trip({})
|
||||
|
||||
self.assertEqual(len(trip.delivery_stops), 1)
|
||||
self.assertEqual(trip.delivery_stops[0].delivery_note, delivery_note.name)
|
||||
|
||||
def test_mapping_keeps_partially_filled_stop(self):
|
||||
_, trip = self.map_delivery_note_onto_trip({"customer": "_Test Customer"})
|
||||
|
||||
self.assertEqual(len(trip.delivery_stops), 2)
|
||||
self.assertIsNone(trip.delivery_stops[0].delivery_note)
|
||||
|
||||
def test_stop_without_address_throws_mandatory_error(self):
|
||||
self.delivery_trip.append("delivery_stops", {"customer": "_Test Customer"})
|
||||
|
||||
self.assertRaises(frappe.MandatoryError, self.delivery_trip.save)
|
||||
|
||||
|
||||
def create_address(driver):
|
||||
if not frappe.db.exists("Address", {"address_title": "_Test Address for Driver"}):
|
||||
|
||||
@@ -73,24 +73,44 @@ frappe.ui.form.on("Inventory Dimension", {
|
||||
frm.trigger("set_parent_fields");
|
||||
},
|
||||
|
||||
set_parent_fields(frm) {
|
||||
if (frm.doc.apply_to_all_doctypes) {
|
||||
let options = ["\n", frm.doc.reference_document];
|
||||
istable(frm) {
|
||||
frm.trigger("set_parent_fields");
|
||||
},
|
||||
|
||||
frm.set_df_property("fetch_from_parent", "options", options);
|
||||
} else if (frm.doc.document_type && frm.doc.istable) {
|
||||
reference_document(frm) {
|
||||
frm.trigger("set_parent_fields");
|
||||
},
|
||||
|
||||
apply_to_all_doctypes(frm) {
|
||||
frm.trigger("set_parent_fields");
|
||||
},
|
||||
|
||||
set_parent_fields(frm) {
|
||||
const { reference_document, document_type } = frm.doc;
|
||||
if (!reference_document || (!frm.doc.apply_to_all_doctypes && (!document_type || !frm.doc.istable))) {
|
||||
return set_parent_field_options(frm, []);
|
||||
}
|
||||
|
||||
if (frm.doc.apply_to_all_doctypes) {
|
||||
return set_parent_field_options(frm, [{ value: reference_document, label: reference_document }]);
|
||||
} else if (document_type && frm.doc.istable) {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_parent_fields",
|
||||
args: {
|
||||
child_doctype: frm.doc.document_type,
|
||||
dimension_name: frm.doc.reference_document,
|
||||
child_doctype: document_type,
|
||||
dimension_name: reference_document,
|
||||
},
|
||||
callback: (r) => {
|
||||
if (r.message && r.message.length) {
|
||||
frm.set_df_property("fetch_from_parent", "options", ["\n"].concat(r.message));
|
||||
} else {
|
||||
frm.set_df_property("fetch_from_parent", "hidden", 1);
|
||||
if (
|
||||
frm.doc.reference_document !== reference_document ||
|
||||
frm.doc.document_type !== document_type ||
|
||||
frm.doc.apply_to_all_doctypes ||
|
||||
!frm.doc.istable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return set_parent_field_options(frm, r.message || []);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -115,3 +135,12 @@ frappe.ui.form.on("Inventory Dimension", {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function set_parent_field_options(frm, fields) {
|
||||
frm.set_df_property("fetch_from_parent", "options", ["", ...fields]);
|
||||
frm.set_df_property("fetch_from_parent", "hidden", !fields.length);
|
||||
|
||||
if (frm.doc.fetch_from_parent && !fields.some((field) => field.value === frm.doc.fetch_from_parent)) {
|
||||
return frm.set_value("fetch_from_parent", "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ class Item(Document):
|
||||
self.validate_conversion_factor()
|
||||
self.validate_item_type()
|
||||
self.validate_naming_series()
|
||||
self.validate_shelf_life()
|
||||
self.check_for_active_boms()
|
||||
self.fill_customer_code()
|
||||
self.check_item_tax()
|
||||
@@ -398,6 +399,19 @@ class Item(Document):
|
||||
).format(self.item_code)
|
||||
)
|
||||
|
||||
def validate_shelf_life(self):
|
||||
if (
|
||||
self.has_batch_no
|
||||
and self.has_expiry_date
|
||||
and self.create_new_batch
|
||||
and cint(self.shelf_life_in_days) <= 0
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} must be greater than zero.").format(
|
||||
self.get_label_from_fieldname("shelf_life_in_days")
|
||||
)
|
||||
)
|
||||
|
||||
def clear_retain_sample(self):
|
||||
if not self.has_batch_no:
|
||||
self.retain_sample = False
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
"idx": 70,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:44.554719",
|
||||
"modified": "2026-09-08 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Material Request",
|
||||
@@ -442,6 +442,11 @@
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Manufacturing Manager"
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager",
|
||||
"select": 1
|
||||
|
||||
@@ -18,6 +18,7 @@ from frappe.utils import cint, flt, get_datetime, get_link_to_form, getdate, new
|
||||
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
|
||||
from erpnext.stock.doctype.price_list.price_list import is_price_list_enabled
|
||||
from erpnext.stock.get_item_details import get_price_list_rate_for
|
||||
from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty
|
||||
|
||||
@@ -209,14 +210,20 @@ class MaterialRequest(BuyingController):
|
||||
self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
|
||||
|
||||
self.validate_pp_qty()
|
||||
self.set_buying_price_list()
|
||||
|
||||
if self.buying_price_list and not frappe.get_value("Price List", self.buying_price_list, "buying"):
|
||||
def set_buying_price_list(self):
|
||||
if not is_valid_buying_price_list(self.buying_price_list):
|
||||
self.buying_price_list = None
|
||||
|
||||
if not self.buying_price_list:
|
||||
buying_price_list = frappe.defaults.get_defaults().buying_price_list
|
||||
if frappe.has_permission("Price List", "read", buying_price_list):
|
||||
self.buying_price_list = buying_price_list
|
||||
if self.buying_price_list:
|
||||
return
|
||||
|
||||
default_price_list = frappe.defaults.get_defaults().buying_price_list
|
||||
if is_valid_buying_price_list(default_price_list) and frappe.has_permission(
|
||||
"Price List", "read", default_price_list
|
||||
):
|
||||
self.buying_price_list = default_price_list
|
||||
|
||||
def on_update(self):
|
||||
if not self.is_new() and self.buying_price_list and self.has_value_changed("buying_price_list"):
|
||||
@@ -507,6 +514,10 @@ class MaterialRequest(BuyingController):
|
||||
doc.db_set("status", doc.status)
|
||||
|
||||
|
||||
def is_valid_buying_price_list(price_list: str | None) -> bool:
|
||||
return is_price_list_enabled(price_list) and bool(frappe.get_value("Price List", price_list, "buying"))
|
||||
|
||||
|
||||
def update_completed_and_requested_qty(stock_entry, method):
|
||||
if stock_entry.doctype == "Stock Entry":
|
||||
material_request_map = {}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
},
|
||||
{
|
||||
"fetch_from": "item_code.item_name",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "item_name",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
@@ -138,15 +139,16 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:08.352880",
|
||||
"modified": "2026-09-09 13:04:53.623636",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Packing Slip Item",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class TestPickList(ERPNextTestSuite):
|
||||
|
||||
def test_pick_list_allocation_takes_advisory_gate(self):
|
||||
if frappe.db.db_type != "postgres":
|
||||
return
|
||||
self.skipTest("advisory locks are a PostgreSQL feature")
|
||||
|
||||
item = make_item(properties={"is_stock_item": 1}).name
|
||||
make_stock_entry(item=item, to_warehouse="_Test Warehouse - _TC", qty=5, basic_rate=100)
|
||||
|
||||
@@ -90,3 +90,7 @@ def get_price_list_details(price_list):
|
||||
frappe.cache().hset("price_list_details", price_list, price_list_details)
|
||||
|
||||
return price_list_details or {}
|
||||
|
||||
|
||||
def is_price_list_enabled(price_list: str | None) -> bool:
|
||||
return bool(price_list) and bool(frappe.get_cached_value("Price List", price_list, "enabled"))
|
||||
|
||||
@@ -200,10 +200,14 @@ def update_billing_percentage(
|
||||
returned_qty = flt(item_wise_returned_qty.get(item.name))
|
||||
returned_amount = flt(returned_qty) * flt(item.rate)
|
||||
pending_amount = flt(item.amount) - returned_amount
|
||||
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
|
||||
pending_amount = flt(item.amount)
|
||||
|
||||
total_billable_amount = abs(flt(item.amount))
|
||||
# When rejected qty is billable, its value is part of the billable base too
|
||||
rejected_amount = 0.0
|
||||
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
|
||||
rejected_amount = flt(item.rejected_qty * item.rate, item.precision("amount"))
|
||||
pending_amount = flt(item.amount) + rejected_amount
|
||||
|
||||
total_billable_amount = abs(flt(item.amount) + rejected_amount)
|
||||
if pending_amount > 0:
|
||||
total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt
|
||||
|
||||
@@ -213,9 +217,7 @@ def update_billing_percentage(
|
||||
if pr_doc.get("is_return") and not total_amount and total_billed_amount:
|
||||
total_amount = total_billed_amount
|
||||
|
||||
amount = item.amount
|
||||
if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
|
||||
amount += flt(item.rejected_qty * item.rate, item.precision("amount"))
|
||||
amount = flt(item.amount) + rejected_amount
|
||||
|
||||
if adjust_incoming_rate:
|
||||
adjusted_amt = 0.0
|
||||
|
||||
@@ -617,6 +617,44 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
return_pr.cancel()
|
||||
pr.cancel()
|
||||
|
||||
def test_per_billed_for_fully_rejected_receipt(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
|
||||
update_billing_percentage,
|
||||
)
|
||||
|
||||
bill_rejected = frappe.db.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1)
|
||||
|
||||
try:
|
||||
# Fully rejected receipt: accepted qty 0, whole qty in rejected warehouse
|
||||
pr = make_purchase_receipt(
|
||||
received_qty=10,
|
||||
qty=0,
|
||||
rejected_qty=10,
|
||||
rate=9.5,
|
||||
rejected_warehouse="_Test Warehouse 1 - _TC",
|
||||
do_not_save=True,
|
||||
)
|
||||
pr.items[0].warehouse = ""
|
||||
pr.submit()
|
||||
|
||||
# Bill the rejected qty (10 x 9.5) directly against the receipt item
|
||||
pr.items[0].db_set("billed_amt", 95)
|
||||
update_billing_percentage(pr)
|
||||
|
||||
pr.load_from_db()
|
||||
# Billing the rejected qty must not push per_billed above 100
|
||||
self.assertEqual(pr.per_billed, 100)
|
||||
self.assertEqual(pr.status, "Completed")
|
||||
|
||||
pr.cancel()
|
||||
finally:
|
||||
frappe.db.set_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", bill_rejected
|
||||
)
|
||||
|
||||
def test_purchase_receipt_for_rejected_gle_without_accepted_warehouse(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
|
||||
def test_outward_batch_valuation_takes_transaction_advisory_lock(self):
|
||||
if frappe.db.db_type != "postgres":
|
||||
return
|
||||
self.skipTest("advisory locks are a PostgreSQL feature")
|
||||
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
@@ -1176,6 +1176,67 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
self.assertEqual(bundle_doc.docstatus, 0)
|
||||
self.assertRaises(frappe.ValidationError, bundle_doc.submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"do_not_use_batchwise_valuation": 0})
|
||||
def test_amended_material_receipt_rate_after_batch_selection(self):
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
for valuation_method in ("FIFO", "Moving Average"):
|
||||
with self.subTest(valuation_method=valuation_method):
|
||||
item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"stock_uom": "Nos",
|
||||
"valuation_method": valuation_method,
|
||||
}
|
||||
)
|
||||
batches = [
|
||||
frappe.get_doc(
|
||||
{"doctype": "Batch", "item": item.name, "batch_id": f"{item.name}-{index}"}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
for index in range(2)
|
||||
]
|
||||
for index, (batch, qty) in enumerate(((batches[0], 10), (batches[1], 10), (batches[0], 5))):
|
||||
receipt = make_stock_entry(
|
||||
item_code=item.name,
|
||||
company="_Test Company",
|
||||
to_warehouse=warehouse,
|
||||
qty=qty,
|
||||
rate=10,
|
||||
batch_no=batch,
|
||||
posting_date=add_days(today(), index - 2),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
|
||||
receipt.cancel()
|
||||
amended = frappe.copy_doc(receipt, ignore_no_copy=False)
|
||||
amended.amended_from = receipt.name
|
||||
amended.docstatus = 0
|
||||
row = amended.items[0]
|
||||
row.batch_no = None
|
||||
row.serial_and_batch_bundle = None
|
||||
row.use_serial_batch_fields = 0
|
||||
|
||||
# The selector returns an unpriced bundle and copies its rate to the receipt row.
|
||||
bundle = add_serial_batch_ledgers(
|
||||
[{"batch_no": batches[1], "qty": 5}],
|
||||
row.as_dict(),
|
||||
amended.as_dict(),
|
||||
warehouse,
|
||||
)
|
||||
row.serial_and_batch_bundle = bundle.name
|
||||
row.basic_rate = bundle.avg_rate
|
||||
amended.insert()
|
||||
self.assertEqual(row.basic_rate, 10)
|
||||
self.assertEqual(row.basic_amount, 50)
|
||||
|
||||
amended.submit()
|
||||
ledger = frappe.get_doc("Stock Ledger Entry", {"voucher_no": amended.name, "is_cancelled": 0})
|
||||
self.assertEqual(ledger.incoming_rate, 10)
|
||||
self.assertEqual(ledger.stock_value_difference, 50)
|
||||
self.assertEqual(ledger.stock_value, 250)
|
||||
|
||||
def test_reference_voucher_on_cancel(self):
|
||||
"""
|
||||
When a source document is cancelled, the reference voucher field
|
||||
|
||||
@@ -26,17 +26,10 @@ class StockEntryGLComposer(BaseStockGLComposer):
|
||||
doc = self.doc
|
||||
gl_entries = super().compose(inventory_account_map)
|
||||
|
||||
if doc.purpose in ("Repack", "Manufacture"):
|
||||
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item)
|
||||
else:
|
||||
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse)
|
||||
|
||||
divide_based_on = total_basic_amount
|
||||
if doc.get("additional_costs") and not total_basic_amount:
|
||||
divide_based_on = sum(item.qty for item in doc.get("items"))
|
||||
incoming_items, basis, divide_based_on = doc.get_additional_cost_allocation()
|
||||
|
||||
item_account_wise_additional_cost = self._build_additional_cost_per_item_account(
|
||||
total_basic_amount, divide_based_on
|
||||
incoming_items, basis, divide_based_on
|
||||
)
|
||||
if item_account_wise_additional_cost:
|
||||
self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost)
|
||||
@@ -183,24 +176,20 @@ class StockEntryGLComposer(BaseStockGLComposer):
|
||||
)
|
||||
|
||||
def _build_additional_cost_per_item_account(
|
||||
self, total_basic_amount: float, divide_based_on: float
|
||||
self, incoming_items: list, basis: str, divide_based_on: float
|
||||
) -> dict:
|
||||
doc = self.doc
|
||||
item_account_wise_additional_cost = {}
|
||||
if not divide_based_on:
|
||||
return item_account_wise_additional_cost
|
||||
|
||||
for t in doc.get("additional_costs"):
|
||||
for d in doc.get("items"):
|
||||
if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
|
||||
continue
|
||||
elif not d.t_warehouse:
|
||||
continue
|
||||
|
||||
for t in self.doc.get("additional_costs"):
|
||||
for d in incoming_items:
|
||||
item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
|
||||
item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
|
||||
t.expense_account, {"amount": 0.0, "base_amount": 0.0}
|
||||
)
|
||||
|
||||
multiply_based_on = d.basic_amount if total_basic_amount else d.qty
|
||||
multiply_based_on = flt(d.get(basis))
|
||||
entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]
|
||||
entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on
|
||||
entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on
|
||||
|
||||
@@ -291,7 +291,7 @@ frappe.ui.form.on("Stock Entry", {
|
||||
frm.trigger("toggle_weight_per_piece");
|
||||
|
||||
// only BOM-less rows are editable, and they cannot allocate a BOM percentage;
|
||||
// read-only rows from a BOM still display their stored % of FG Cost
|
||||
// read-only rows from a BOM still display their stored % of Component Cost
|
||||
frm.fields_dict.items.grid.update_docfield_property("valuation_type", "options", [
|
||||
"Valuation Rate",
|
||||
"Manual",
|
||||
|
||||
@@ -808,7 +808,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
def set_bomless_secondary_valuation_types(self):
|
||||
"""Secondary rows without a BOM link choose their own costing: valuation rate or manual.
|
||||
|
||||
There is no percentage to allocate without a BOM row, so % of FG Cost is rejected."""
|
||||
There is no percentage to allocate without a BOM row, so % of Component Cost is rejected."""
|
||||
for d in self.get("items"):
|
||||
if d.bom_secondary_item:
|
||||
continue
|
||||
@@ -819,10 +819,10 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
d.set_basic_rate_manually = 0
|
||||
continue
|
||||
|
||||
if d.valuation_type == "% of FG Cost":
|
||||
if d.valuation_type == "% of Component Cost":
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: % of FG Cost needs a BOM secondary item. Choose Valuation Rate or Manual for {1}."
|
||||
"Row #{0}: % of Component Cost needs a BOM secondary item. Choose Valuation Rate or Manual for {1}."
|
||||
).format(d.idx, frappe.bold(d.item_code))
|
||||
)
|
||||
|
||||
@@ -920,22 +920,28 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
self.total_additional_costs = sum(flt(t.base_amount) for t in self.get("additional_costs"))
|
||||
|
||||
if self.purpose in ("Repack", "Manufacture"):
|
||||
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item)
|
||||
else:
|
||||
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse)
|
||||
|
||||
if not incoming_items_cost:
|
||||
return
|
||||
incoming_items, basis, total_basis = self.get_additional_cost_allocation()
|
||||
|
||||
for d in self.get("items"):
|
||||
if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
|
||||
d.additional_cost = 0
|
||||
continue
|
||||
elif not d.t_warehouse:
|
||||
d.additional_cost = 0
|
||||
continue
|
||||
d.additional_cost = (flt(d.basic_amount) / incoming_items_cost) * self.total_additional_costs
|
||||
d.additional_cost = 0
|
||||
|
||||
if not total_basis:
|
||||
return
|
||||
|
||||
for d in incoming_items:
|
||||
d.additional_cost = (flt(d.get(basis)) / total_basis) * self.total_additional_costs
|
||||
|
||||
def get_additional_cost_allocation(self):
|
||||
if self.purpose in ("Repack", "Manufacture"):
|
||||
incoming_items = [d for d in self.get("items") if d.is_finished_item]
|
||||
else:
|
||||
incoming_items = [d for d in self.get("items") if d.t_warehouse]
|
||||
|
||||
total_basic_amount = sum(flt(d.basic_amount) for d in incoming_items)
|
||||
if total_basic_amount:
|
||||
return incoming_items, "basic_amount", total_basic_amount
|
||||
|
||||
return incoming_items, "transfer_qty", sum(flt(d.transfer_qty) for d in incoming_items)
|
||||
|
||||
def update_valuation_rate(self, reset_outgoing_rate=True):
|
||||
for d in self.get("items"):
|
||||
|
||||
@@ -1362,7 +1362,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
"secondary_item_type": "By-Product",
|
||||
"qty": 1,
|
||||
"cost_allocation_per": 10,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
},
|
||||
)
|
||||
bom_doc.save()
|
||||
@@ -1446,7 +1446,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
"secondary_item_type": "By-Product",
|
||||
"qty": 1,
|
||||
"cost_allocation_per": 10,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
},
|
||||
)
|
||||
bom_doc.save()
|
||||
@@ -1660,7 +1660,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
self.assertEqual(fg_row.basic_amount, 830)
|
||||
|
||||
# there is no percentage to allocate without a BOM row
|
||||
manual_row.valuation_type = "% of FG Cost"
|
||||
manual_row.valuation_type = "% of Component Cost"
|
||||
self.assertRaises(frappe.ValidationError, entry.save)
|
||||
|
||||
def test_valuation_rate_lookup_without_voucher_no(self):
|
||||
@@ -2233,10 +2233,12 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
self.assertEqual([33.33, 66.67], [flt(d.additional_cost, 2) for d in se.items])
|
||||
|
||||
self.check_gl_entries(
|
||||
"Stock Entry",
|
||||
se.name,
|
||||
sorted([["Stock Adjustment - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
|
||||
sorted([["Stock In Hand - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
|
||||
)
|
||||
|
||||
def test_conversion_factor_change(self):
|
||||
@@ -2282,6 +2284,184 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
distributed_costs = [d.additional_cost for d in se.items]
|
||||
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
|
||||
|
||||
def test_additional_cost_distribution_manufacture_zero_valued_items(self):
|
||||
se = frappe.get_doc(
|
||||
doctype="Stock Entry",
|
||||
purpose="Manufacture",
|
||||
additional_costs=[frappe._dict(base_amount=100)],
|
||||
items=[
|
||||
frappe._dict(item_code="RM", basic_amount=0, transfer_qty=10),
|
||||
frappe._dict(
|
||||
item_code="FG", basic_amount=0, transfer_qty=5, t_warehouse="X", is_finished_item=1
|
||||
),
|
||||
frappe._dict(item_code="scrap", basic_amount=0, transfer_qty=2, t_warehouse="X"),
|
||||
],
|
||||
)
|
||||
|
||||
se.distribute_additional_costs()
|
||||
|
||||
distributed_costs = [d.additional_cost for d in se.items]
|
||||
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
|
||||
|
||||
def test_additional_cost_distribution_zero_valued_items(self):
|
||||
se = frappe.get_doc(
|
||||
doctype="Stock Entry",
|
||||
purpose="Material Receipt",
|
||||
additional_costs=[frappe._dict(base_amount=100)],
|
||||
items=[
|
||||
frappe._dict(item_code="RECEIVED_1", basic_amount=0, transfer_qty=20, t_warehouse="X"),
|
||||
frappe._dict(item_code="RECEIVED_2", basic_amount=0, transfer_qty=30, t_warehouse="X"),
|
||||
],
|
||||
)
|
||||
|
||||
se.distribute_additional_costs()
|
||||
|
||||
distributed_costs = [d.additional_cost for d in se.items]
|
||||
self.assertEqual([40.0, 60.0], distributed_costs)
|
||||
|
||||
def test_additional_cost_gl_for_zero_valued_manufacture(self):
|
||||
company = "_Test Company with perpetual inventory"
|
||||
rm = make_item("_Test Zero Rate RM", {"is_stock_item": 1}).name
|
||||
fg = make_item("_Test Zero Rate FG", {"is_stock_item": 1}).name
|
||||
|
||||
receipt = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Entry",
|
||||
"purpose": "Material Receipt",
|
||||
"stock_entry_type": "Material Receipt",
|
||||
"posting_date": nowdate(),
|
||||
"company": company,
|
||||
"items": [
|
||||
{
|
||||
"item_code": rm,
|
||||
"qty": 5,
|
||||
"basic_rate": 0,
|
||||
"uom": "Nos",
|
||||
"t_warehouse": "Stores - TCP1",
|
||||
"allow_zero_valuation_rate": 1,
|
||||
"cost_center": "Main - TCP1",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
receipt.insert()
|
||||
receipt.submit()
|
||||
|
||||
se = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Entry",
|
||||
"purpose": "Manufacture",
|
||||
"stock_entry_type": "Manufacture",
|
||||
"posting_date": nowdate(),
|
||||
"company": company,
|
||||
"items": [
|
||||
{
|
||||
"item_code": rm,
|
||||
"qty": 5,
|
||||
"uom": "Nos",
|
||||
"s_warehouse": "Stores - TCP1",
|
||||
"cost_center": "Main - TCP1",
|
||||
},
|
||||
{
|
||||
"item_code": fg,
|
||||
"qty": 5,
|
||||
"uom": "Nos",
|
||||
"t_warehouse": "Finished Goods - TCP1",
|
||||
"is_finished_item": 1,
|
||||
"cost_center": "Main - TCP1",
|
||||
},
|
||||
],
|
||||
"additional_costs": [
|
||||
{
|
||||
"expense_account": "Miscellaneous Expenses - TCP1",
|
||||
"amount": 500,
|
||||
"description": "freight",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
self.assertEqual(500.0, se.items[1].additional_cost)
|
||||
self.check_gl_entries(
|
||||
"Stock Entry",
|
||||
se.name,
|
||||
sorted([["Stock In Hand - TCP1", 500.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 500.0]]),
|
||||
)
|
||||
|
||||
def test_additional_cost_gl_matches_valuation_split(self):
|
||||
company = "_Test Company with perpetual inventory"
|
||||
cost_center = "_Test Additional Cost CC - TCP1"
|
||||
if not frappe.db.exists("Cost Center", cost_center):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "_Test Additional Cost CC",
|
||||
"company": company,
|
||||
"is_group": 0,
|
||||
"parent_cost_center": "_Test Company with perpetual inventory - TCP1",
|
||||
}
|
||||
).insert()
|
||||
|
||||
uoms = [{"uom": "Nos", "conversion_factor": 1}, {"uom": "Box", "conversion_factor": 2}]
|
||||
item_a = make_item("_Test Addl Cost CF A", {"is_stock_item": 1, "uoms": uoms}).name
|
||||
uoms[1]["conversion_factor"] = 3
|
||||
item_b = make_item("_Test Addl Cost CF B", {"is_stock_item": 1, "uoms": uoms}).name
|
||||
|
||||
se = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Entry",
|
||||
"purpose": "Material Receipt",
|
||||
"stock_entry_type": "Material Receipt",
|
||||
"posting_date": nowdate(),
|
||||
"company": company,
|
||||
"items": [
|
||||
{
|
||||
"item_code": item_a,
|
||||
"qty": 1,
|
||||
"basic_rate": 0,
|
||||
"uom": "Box",
|
||||
"conversion_factor": 2,
|
||||
"t_warehouse": "Stores - TCP1",
|
||||
"allow_zero_valuation_rate": 1,
|
||||
"cost_center": "Main - TCP1",
|
||||
},
|
||||
{
|
||||
"item_code": item_b,
|
||||
"qty": 1,
|
||||
"basic_rate": 0,
|
||||
"uom": "Box",
|
||||
"conversion_factor": 3,
|
||||
"t_warehouse": "Stores - TCP1",
|
||||
"allow_zero_valuation_rate": 1,
|
||||
"cost_center": cost_center,
|
||||
},
|
||||
],
|
||||
"additional_costs": [
|
||||
{
|
||||
"expense_account": "Miscellaneous Expenses - TCP1",
|
||||
"amount": 100,
|
||||
"description": "misc",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
self.assertEqual([40.0, 60.0], [flt(d.additional_cost, 2) for d in se.items])
|
||||
|
||||
expense_by_cost_center = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": se.name, "account": "Miscellaneous Expenses - TCP1"},
|
||||
fields=["cost_center", "credit"],
|
||||
)
|
||||
self.assertEqual(
|
||||
{"Main - TCP1": 40.0, cost_center: 60.0},
|
||||
{d.cost_center: d.credit for d in expense_by_cost_center},
|
||||
)
|
||||
|
||||
def test_additional_cost_distribution_non_manufacture(self):
|
||||
se = frappe.get_doc(
|
||||
doctype="Stock Entry",
|
||||
@@ -3478,7 +3658,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
"qty": 5,
|
||||
"cost_allocation_per": 25,
|
||||
"process_loss_per": 0,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
},
|
||||
)
|
||||
bom.insert()
|
||||
@@ -3540,7 +3720,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
"qty": 5,
|
||||
"cost_allocation_per": 0,
|
||||
"process_loss_per": 0,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
},
|
||||
)
|
||||
bom.insert()
|
||||
@@ -3597,7 +3777,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
"qty": 5,
|
||||
"cost_allocation_per": 25,
|
||||
"process_loss_per": 0,
|
||||
"valuation_type": "% of FG Cost",
|
||||
"valuation_type": "% of Component Cost",
|
||||
},
|
||||
)
|
||||
bom.insert()
|
||||
@@ -4264,11 +4444,6 @@ class TestStockEntryCoverage(ERPNextTestSuite):
|
||||
|
||||
# ── validate_source_stock_entry ────────────────────────────────────────────
|
||||
|
||||
def test_validate_source_stock_entry_skips_when_no_source(self):
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.source_stock_entry = None
|
||||
se.validate_source_stock_entry() # must not raise
|
||||
|
||||
def test_validate_source_stock_entry_throws_on_work_order_mismatch(self):
|
||||
source_se = make_stock_entry(
|
||||
item_code="_Test Item",
|
||||
@@ -4298,62 +4473,6 @@ class TestStockEntryCoverage(ERPNextTestSuite):
|
||||
se.work_order = "WO-SAME-001"
|
||||
se.validate_source_stock_entry() # must not raise
|
||||
|
||||
# ── validate_job_card_fg_item ──────────────────────────────────────────────
|
||||
|
||||
def test_validate_job_card_fg_item_skips_when_no_job_card(self):
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.job_card = None
|
||||
se.validate_job_card_fg_item() # must not raise
|
||||
|
||||
def test_validate_job_card_fg_item_throws_when_fg_item_mismatches(self):
|
||||
wrong_fg = make_item("_JC Wrong FG Item", {"is_stock_item": 1}).name
|
||||
|
||||
jc_name = frappe.db.get_value("Job Card", {"docstatus": 1, "finished_good": ("!=", "")})
|
||||
if not jc_name:
|
||||
return # skip if no suitable job card in test data
|
||||
|
||||
jc = frappe.db.get_value("Job Card", jc_name, ["finished_good"], as_dict=1)
|
||||
if jc.finished_good == wrong_fg:
|
||||
return # skip if the wrong_fg happens to match
|
||||
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.job_card = jc_name
|
||||
se.append("items", {"item_code": wrong_fg, "is_finished_item": 1, "qty": 1})
|
||||
self.assertRaises(frappe.ValidationError, se.validate_job_card_fg_item)
|
||||
|
||||
# ── validate_job_card_item ─────────────────────────────────────────────────
|
||||
|
||||
def test_validate_job_card_item_skips_when_no_job_card(self):
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.job_card = None
|
||||
se.validate_job_card_item() # must not raise
|
||||
|
||||
def test_validate_job_card_item_skips_for_manufacture_purpose(self):
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.job_card = "SOME-JC-001"
|
||||
se.purpose = "Manufacture"
|
||||
se.validate_job_card_item() # must not raise even with a job card set
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
|
||||
def test_validate_job_card_item_throws_when_job_card_item_ref_missing(self):
|
||||
jc_name = frappe.db.get_value("Job Card", {"docstatus": 1})
|
||||
if not jc_name:
|
||||
return # skip if no job cards in test data
|
||||
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.job_card = jc_name
|
||||
se.purpose = "Material Transfer for Manufacture"
|
||||
se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"s_warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 1,
|
||||
"job_card_item": None,
|
||||
},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, se.validate_job_card_item)
|
||||
|
||||
# ── get_available_materials ────────────────────────────────────────────────
|
||||
|
||||
def test_get_available_materials_tracks_transferred_qty(self):
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"fieldname": "valuation_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Valuation Type",
|
||||
"options": "\nValuation Rate\n% of FG Cost\nManual",
|
||||
"options": "\nValuation Rate\n% of Component Cost\nManual",
|
||||
"read_only_depends_on": "eval:!doc.secondary_item_type || doc.bom_secondary_item"
|
||||
}
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user