Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
69075c7db0 fix(accounts): add account to GROUP BY for PostgreSQL compatibility, fix ruff formatting 2026-06-10 05:34:47 +00:00
copilot-swe-agent[bot]
ffd0dbdfd9 Initial plan 2026-06-10 05:29:00 +00:00
106 changed files with 30817 additions and 102366 deletions

View File

@@ -1,10 +0,0 @@
{
"disabledLabels": [
"conflicts"
],
"context": {
"repos": [
"frappe/frappe"
]
}
}

View File

@@ -94,7 +94,6 @@ class BankClearance(Document):
invalid_document = []
invalid_cheque_date = []
entries_to_update = []
self.check_permission("write")
def validate_entry(d):
is_valid = True

View File

@@ -518,7 +518,6 @@ def create_internal_transfer(
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_transaction.check_permission("write")
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
company = frappe.get_cached_value("Account", bank_account, "company")
@@ -779,6 +778,7 @@ def create_bulk_payment_entry_and_reconcile(
"""
Create a payment entry and reconcile it with the bank transaction
"""
output = []
for bank_transaction_name in bank_transaction_names:

View File

@@ -374,7 +374,6 @@ def unreconcile_transaction(transaction_name: str | int):
Else, cancel the individual entries
"""
transaction = frappe.get_doc("Bank Transaction", transaction_name)
transaction.check_permission("write")
vouchers_to_cancel = []
@@ -402,7 +401,6 @@ def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type:
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_id)
bank_transaction.check_permission("write")
# Find the voucher in the bank transaction and depending on the action, either remove it or cancel the voucher
for entry in bank_transaction.payment_entries:

View File

@@ -409,16 +409,18 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
}
get_outstanding(doctype, docname, company, child) {
var args = {
doctype: doctype,
docname: docname,
party: child.party,
account: child.account,
account_currency: child.account_currency,
company: company,
};
return frappe.call({
method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding",
args: {
doctype: doctype,
docname: docname,
company: company,
account: child.account,
party: child.party,
account_currency: child.account_currency,
},
args: { args: args },
callback: function (r) {
if (r.message) {
$.each(r.message, function (field, value) {

View File

@@ -8,7 +8,6 @@ import frappe
from frappe import _, msgprint, scrub
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
import erpnext
@@ -44,14 +43,6 @@ class StockAccountInvalidTransaction(frappe.ValidationError):
class JournalEntry(AccountsController):
"""Double-entry accounting voucher for manual and system-generated postings.
Besides plain journal entries it also backs depreciation, asset disposal,
exchange gain/loss, deferred revenue/expense, inter-company and periodic
accounting entries: it validates the account rows (party, references,
currency) and posts the corresponding GL entries on submit.
"""
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -137,7 +128,6 @@ class JournalEntry(AccountsController):
super().__init__(*args, **kwargs)
def validate(self):
"""Validate the account rows (party, references, currency, stock) and build derived fields."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
from erpnext.accounts.doctype.journal_entry.services.reference_validator import (
JournalEntryReferenceValidator,
@@ -198,33 +188,28 @@ class JournalEntry(AccountsController):
validate_docs_for_deferred_accounting([self.name], [])
def submit(self):
"""Submit inline, or queue submission in the background for large entries."""
if len(self.accounts) > 100 and not self.meta.queue_in_background:
queue_submission(self, "_submit")
else:
return self._submit()
def before_cancel(self):
"""Block cancellation when a submitted Asset Value Adjustment is linked to this entry."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
AssetService(self).has_asset_adjustment_entry()
def cancel(self):
"""Cancel inline, or queue cancellation in the background for large entries."""
if len(self.accounts) > 100:
queue_submission(self, "_cancel")
else:
return self._cancel()
def before_submit(self):
"""Ensure total debit equals total credit before submission (skipped on data import)."""
# Do not validate while importing via data import
if not frappe.flags.in_import:
self.validate_total_debit_and_credit()
def on_submit(self):
"""Post GL entries and propagate the submission to assets, inter-company JE and invoice discounting."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
self.validate_cheque_info()
@@ -236,16 +221,18 @@ class JournalEntry(AccountsController):
JournalTaxWithholding(self).on_submit()
@frappe.whitelist()
def get_balance_for_periodic_accounting(self) -> None:
"""Rebuild the entry rows from the stock-vs-ledger difference of each stock account."""
def get_balance_for_periodic_accounting(self):
self.validate_company_for_periodic_accounting()
stock_accounts = self.get_stock_accounts_for_periodic_accounting()
self.set("accounts", [])
for account in self.get_stock_accounts_for_periodic_accounting():
account_bal, stock_bal, _warehouse_list = get_stock_and_account_balance(
for account in stock_accounts:
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
account, self.posting_date, self.company
)
difference_value = flt(stock_bal - account_bal, self.precision("difference"))
if difference_value == 0:
frappe.msgprint(
_("No difference found for stock account {0}").format(frappe.bold(account)),
@@ -253,26 +240,23 @@ class JournalEntry(AccountsController):
)
continue
self._append_periodic_difference_rows(account, difference_value)
self.append(
"accounts",
{
"account": account,
"debit_in_account_currency": difference_value if difference_value > 0 else 0,
"credit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
def _append_periodic_difference_rows(self, account: str, difference_value: float) -> None:
"""Append the stock account row and its offsetting difference-account row."""
self.append(
"accounts",
{
"account": account,
"debit_in_account_currency": difference_value if difference_value > 0 else 0,
"credit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
self.append(
"accounts",
{
"account": self.periodic_entry_difference_account,
"credit_in_account_currency": difference_value if difference_value > 0 else 0,
"debit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
self.append(
"accounts",
{
"account": self.periodic_entry_difference_account,
"credit_in_account_currency": difference_value if difference_value > 0 else 0,
"debit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
def validate_company_for_periodic_accounting(self):
if erpnext.is_perpetual_inventory_enabled(self.company):
@@ -318,7 +302,6 @@ class JournalEntry(AccountsController):
self.repost_accounting_entries()
def on_cancel(self):
"""Reverse GL entries and unlink asset, inter-company and advance references on cancel."""
# Cancel tax withholding entries
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
@@ -402,44 +385,49 @@ class JournalEntry(AccountsController):
self.name,
)
def update_invoice_discounting(self) -> None:
"""Advance each linked Invoice Discounting to its next status on submit/cancel."""
discounting_names = {
row.reference_name for row in self.accounts if row.reference_type == "Invoice Discounting"
}
for name in discounting_names:
inv_disc = frappe.get_doc("Invoice Discounting", name)
if status := self._get_next_invoice_discounting_status(inv_disc):
inv_disc.set_status(status=status)
def _get_next_invoice_discounting_status(self, inv_disc) -> str | None:
"""Validate the current status and return the next one from the loan account row."""
for row in self.accounts:
if row.account != inv_disc.short_term_loan or row.reference_name != inv_disc.name:
continue
submitting = self.docstatus == 1
if row.credit > 0:
expected, next_status = (
("Sanctioned", "Disbursed") if submitting else ("Disbursed", "Sanctioned")
def update_invoice_discounting(self):
def _validate_invoice_discounting_status(inv_disc, id_status, expected_status, row_id):
id_link = get_link_to_form("Invoice Discounting", inv_disc)
if id_status != expected_status:
frappe.throw(
_("Row #{0}: Status must be {1} for Invoice Discounting {2}").format(
d.idx, expected_status, id_link
)
)
elif row.debit > 0:
expected, next_status = ("Disbursed", "Settled") if submitting else ("Settled", "Disbursed")
else:
return None
self._validate_invoice_discounting_status(inv_disc, expected, row.idx)
return next_status
return None
def _validate_invoice_discounting_status(self, inv_disc, expected_status: str, row_idx: int) -> None:
"""Throw unless the Invoice Discounting is in the status expected for this transition."""
if inv_disc.status != expected_status:
frappe.throw(
_("Row #{0}: Status must be {1} for Invoice Discounting {2}").format(
row_idx, expected_status, get_link_to_form("Invoice Discounting", inv_disc.name)
)
)
invoice_discounting_list = list(
set([d.reference_name for d in self.accounts if d.reference_type == "Invoice Discounting"])
)
for inv_disc in invoice_discounting_list:
inv_disc_doc = frappe.get_doc("Invoice Discounting", inv_disc)
status = None
for d in self.accounts:
if d.account == inv_disc_doc.short_term_loan and d.reference_name == inv_disc:
if self.docstatus == 1:
if d.credit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Sanctioned", d.idx
)
status = "Disbursed"
elif d.debit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Disbursed", d.idx
)
status = "Settled"
else:
if d.credit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Disbursed", d.idx
)
status = "Sanctioned"
elif d.debit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Settled", d.idx
)
status = "Disbursed"
break
if status:
inv_disc_doc.set_status(status=status)
def unlink_advance_entry_reference(self):
for d in self.get("accounts"):
@@ -555,76 +543,62 @@ class JournalEntry(AccountsController):
self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency and self.is_system_generated
)
def validate_against_jv(self) -> None:
"""Validate every account row that references another Journal Entry."""
for row in self.get("accounts"):
if row.reference_type == "Journal Entry":
self._validate_jv_reference(row)
def validate_against_jv(self):
for d in self.get("accounts"):
if d.reference_type == "Journal Entry":
account_root_type = frappe.get_cached_value("Account", d.account, "root_type")
if (
account_root_type == "Asset"
and flt(d.debit) > 0
and not self.system_generated_gain_loss()
):
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets credited"
).format(d.idx, d.account)
)
elif (
account_root_type == "Liability"
and flt(d.credit) > 0
and not self.system_generated_gain_loss()
):
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets debited"
).format(d.idx, d.account)
)
def _validate_jv_reference(self, row) -> None:
"""Validate a single 'Against Journal Entry' row: direction, no self-reference,
and the presence of an unmatched entry on the referenced Journal Entry."""
self._validate_jv_reference_direction(row)
if d.reference_name == self.name:
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
if row.reference_name == self.name:
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
against_entries = self._get_against_jv_entries(row)
if not against_entries:
if self.voucher_type != "Exchange Gain Or Loss":
frappe.throw(
_(
"Journal Entry {0} does not have account {1} or already matched against other voucher"
).format(row.reference_name, row.account)
against_entries = frappe.db.sql(
"""select * from `tabJournal Entry Account`
where account = %s and docstatus = 1 and parent = %s
and (reference_type is null or reference_type in ('', 'Sales Order', 'Purchase Order'))
""",
(d.account, d.reference_name),
as_dict=True,
)
return
dr_or_cr = "debit" if flt(row.credit) > 0 else "credit"
has_unmatched_entry = any(flt(entry[dr_or_cr]) > 0 for entry in against_entries)
if not has_unmatched_entry and not self.system_generated_gain_loss():
frappe.throw(
_("Against Journal Entry {0} does not have any unmatched {1} entry").format(
row.reference_name, dr_or_cr
)
)
def _validate_jv_reference_direction(self, row) -> None:
"""An asset account can reference a JE only when credited, a liability only when debited."""
if self.system_generated_gain_loss():
return
account_root_type = frappe.get_cached_value("Account", row.account, "root_type")
if account_root_type == "Asset" and flt(row.debit) > 0:
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets credited"
).format(row.idx, row.account)
)
if account_root_type == "Liability" and flt(row.credit) > 0:
frappe.throw(
_("Row #{0}: For {1}, you can select reference document only if account gets debited").format(
row.idx, row.account
)
)
def _get_against_jv_entries(self, row) -> list[dict]:
"""Submitted Journal Entry Account rows on the referenced JE for the same account
that are not themselves linked to an order."""
jea = frappe.qb.DocType("Journal Entry Account")
return (
frappe.qb.from_(jea)
.select(jea.star)
.where(
(jea.account == row.account)
& (jea.docstatus == 1)
& (jea.parent == row.reference_name)
& (
jea.reference_type.isnull()
| jea.reference_type.isin(["", "Sales Order", "Purchase Order"])
)
)
.run(as_dict=True)
)
if not against_entries:
if self.voucher_type != "Exchange Gain Or Loss":
frappe.throw(
_(
"Journal Entry {0} does not have account {1} or already matched against other voucher"
).format(d.reference_name, d.account)
)
else:
dr_or_cr = "debit" if flt(d.credit) > 0 else "credit"
valid = False
for jvd in against_entries:
if flt(jvd[dr_or_cr]) > 0:
valid = True
if not valid and not self.system_generated_gain_loss():
frappe.throw(
_("Against Journal Entry {0} does not have any unmatched {1} entry").format(
d.reference_name, dr_or_cr
)
)
def set_against_account(self):
accounts_debited, accounts_credited = [], []
@@ -712,142 +686,131 @@ class JournalEntry(AccountsController):
d.debit = flt(d.debit_in_account_currency * flt(d.exchange_rate), d.precision("debit"))
d.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit"))
def set_exchange_rate(self) -> None:
"""Resolve a mandatory exchange rate for every account row."""
for row in self.get("accounts"):
self._set_row_exchange_rate(row)
if not row.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(row.idx))
def set_exchange_rate(self):
for d in self.get("accounts"):
if d.account_currency == self.company_currency:
d.exchange_rate = 1
elif (
not d.exchange_rate
or d.exchange_rate == 1
or (
d.reference_type in ("Sales Invoice", "Purchase Invoice")
and d.reference_name
and self.posting_date
)
):
ignore_exchange_rate = False
if self.get("flags") and self.flags.get("ignore_exchange_rate"):
ignore_exchange_rate = True
def _set_row_exchange_rate(self, row) -> None:
"""Set a row's exchange rate: 1 for company currency, otherwise fetched when stale."""
if row.account_currency == self.company_currency:
row.exchange_rate = 1
if not ignore_exchange_rate:
# Modified to include the posting date for which to retreive the exchange rate
d.exchange_rate = get_exchange_rate(
self.posting_date,
d.account,
d.account_currency,
self.company,
d.reference_type,
d.reference_name,
d.debit,
d.credit,
d.exchange_rate,
)
if not d.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx))
def create_remarks(self):
r = []
if self.flags.skip_remarks_creation:
return
needs_refresh = (
not row.exchange_rate
or row.exchange_rate == 1
or (
row.reference_type in ("Sales Invoice", "Purchase Invoice")
and row.reference_name
and self.posting_date
)
)
if not needs_refresh or self.flags.get("ignore_exchange_rate"):
if self.get("custom_remark"):
return
# Includes the posting date for which to retrieve the exchange rate
row.exchange_rate = get_exchange_rate(
self.posting_date,
row.account,
row.account_currency,
self.company,
row.reference_type,
row.reference_name,
row.debit,
row.credit,
row.exchange_rate,
)
def create_remarks(self) -> None:
"""Build the auto remark from the cheque reference and each account row's linked
document, unless remark creation is skipped or a custom remark is set."""
if self.flags.skip_remarks_creation or self.get("custom_remark"):
return
remarks = []
if cheque_remark := self._get_cheque_remark():
remarks.append(cheque_remark)
for row in self.get("accounts"):
if reference_remark := self._get_reference_remark(row):
remarks.append(reference_remark)
if remarks:
self.remark = "\n".join(remarks) # User Remarks is not mandatory
def _get_cheque_remark(self) -> str | None:
"""Remark line for the cheque reference; raises if the cheque date is missing."""
if not self.cheque_no:
return None
if not self.cheque_date:
msgprint(_("Please enter Reference date"), raise_exception=frappe.MandatoryError)
return _("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date))
def _get_reference_remark(self, row) -> str | None:
"""Remark line for a single account row's linked Invoice/Order, or None."""
if row.reference_type == "Sales Invoice" and row.credit:
return _("{0} against Sales Invoice {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
if row.reference_type == "Sales Order" and row.credit:
return _("{0} against Sales Order {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
if row.reference_type == "Purchase Invoice" and row.debit:
return self._get_bill_remark(row)
if row.reference_type == "Purchase Order" and row.debit:
return _("{0} against Purchase Order {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
return None
def _get_bill_remark(self, row) -> str | None:
"""Remark line referencing the supplier bill number/date of a Purchase Invoice row."""
bill_no, bill_date = frappe.db.get_value(
"Purchase Invoice", row.reference_name, ["bill_no", "bill_date"]
) or (None, None)
if not bill_no or bill_no.lower().strip() in ["na", "not applicable", "none"]:
return None
return _("{0} against Bill {1} dated {2}").format(
fmt_money(flt(row.debit), currency=self.company_currency),
bill_no,
bill_date and formatdate(bill_date.strftime("%Y-%m-%d")),
)
def set_print_format_fields(self) -> None:
"""Populate pay_to_recd_from and the total amount/currency shown on the print format."""
amounts = self._get_party_and_bank_amounts()
total_amount, currency = 0.0, None
if amounts.party_type and amounts.pay_to_recd_from:
self.pay_to_recd_from = frappe.db.get_value(
amounts.party_type,
amounts.pay_to_recd_from,
"customer_name" if amounts.party_type == "Customer" else "supplier_name",
)
if amounts.bank_amount:
total_amount, currency = amounts.bank_amount, amounts.bank_account_currency
if self.cheque_no:
if self.cheque_date:
r.append(_("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date)))
else:
total_amount, currency = amounts.party_amount, amounts.party_account_currency
msgprint(_("Please enter Reference date"), raise_exception=frappe.MandatoryError)
for d in self.get("accounts"):
if d.reference_type == "Sales Invoice" and d.credit:
r.append(
_("{0} against Sales Invoice {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if d.reference_type == "Sales Order" and d.credit:
r.append(
_("{0} against Sales Order {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if d.reference_type == "Purchase Invoice" and d.debit:
bill_no = frappe.db.sql(
"""select bill_no, bill_date
from `tabPurchase Invoice` where name=%s""",
d.reference_name,
)
if (
bill_no
and bill_no[0][0]
and bill_no[0][0].lower().strip() not in ["na", "not applicable", "none"]
):
r.append(
_("{0} against Bill {1} dated {2}").format(
fmt_money(flt(d.debit), currency=self.company_currency),
bill_no[0][0],
bill_no[0][1] and formatdate(bill_no[0][1].strftime("%Y-%m-%d")),
)
)
if d.reference_type == "Purchase Order" and d.debit:
r.append(
_("{0} against Purchase Order {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if r:
self.remark = ("\n").join(r) # User Remarks is not mandatory
def set_print_format_fields(self):
bank_amount = party_amount = total_amount = 0.0
currency = bank_account_currency = party_account_currency = pay_to_recd_from = None
party_type = None
for d in self.get("accounts"):
if d.party_type in ["Customer", "Supplier"] and d.party:
party_type = d.party_type
if not pay_to_recd_from:
pay_to_recd_from = d.party
if pay_to_recd_from and pay_to_recd_from == d.party:
party_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
party_account_currency = d.account_currency
elif frappe.get_cached_value("Account", d.account, "account_type") in ["Bank", "Cash"]:
bank_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
bank_account_currency = d.account_currency
if party_type and pay_to_recd_from:
self.pay_to_recd_from = frappe.db.get_value(
party_type, pay_to_recd_from, "customer_name" if party_type == "Customer" else "supplier_name"
)
if bank_amount:
total_amount = bank_amount
currency = bank_account_currency
else:
total_amount = party_amount
currency = party_account_currency
self.set_total_amount(total_amount, currency)
def _get_party_and_bank_amounts(self) -> frappe._dict:
"""Sum the party and bank/cash amounts, with their currencies, across the account rows."""
totals = frappe._dict(
bank_amount=0.0,
party_amount=0.0,
bank_account_currency=None,
party_account_currency=None,
pay_to_recd_from=None,
party_type=None,
)
for row in self.get("accounts"):
amount = flt(row.debit_in_account_currency) or flt(row.credit_in_account_currency)
if row.party_type in ["Customer", "Supplier"] and row.party:
totals.party_type = row.party_type
totals.pay_to_recd_from = totals.pay_to_recd_from or row.party
if totals.pay_to_recd_from == row.party:
totals.party_amount += amount
totals.party_account_currency = row.account_currency
elif frappe.get_cached_value("Account", row.account, "account_type") in ["Bank", "Cash"]:
totals.bank_amount += amount
totals.bank_account_currency = row.account_currency
return totals
def set_total_amount(self, amt: float, currency: str) -> None:
def set_total_amount(self, amt, currency):
self.total_amount = amt
self.total_amount_currency = currency
from frappe.utils import money_in_words
@@ -859,7 +822,7 @@ class JournalEntry(AccountsController):
return JournalEntryGLComposer(self).compose()
def make_gl_entries(self, cancel: int = 0, adv_adj: int = 0) -> None:
def make_gl_entries(self, cancel=0, adv_adj=0):
from erpnext.accounts.general_ledger import make_gl_entries
merge_entries = frappe.get_single_value("Accounts Settings", "merge_similar_account_heads")
@@ -883,109 +846,94 @@ class JournalEntry(AccountsController):
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
@frappe.whitelist()
def get_balance(self, difference_account: str | None = None) -> None:
"""Balance the entry by placing any difference on a blank (or newly added) row."""
def get_balance(self, difference_account: str | None = None):
if not self.get("accounts"):
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
return
else:
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
if diff:
self._apply_difference_to_blank_row(diff, difference_account)
# If any row without amount, set the diff on that row
if diff:
blank_row = None
for d in self.get("accounts"):
if not d.credit_in_account_currency and not d.debit_in_account_currency and diff != 0:
blank_row = d
self.set_total_debit_credit()
self.validate_total_debit_and_credit()
if not blank_row:
blank_row = self.append(
"accounts",
{
"account": difference_account,
"cost_center": erpnext.get_default_cost_center(self.company),
},
)
def _apply_difference_to_blank_row(self, diff: float, difference_account: str | None) -> None:
"""Set the balancing difference on the last amountless row, adding one if none exists."""
blank_row = None
for row in self.get("accounts"):
if not row.credit_in_account_currency and not row.debit_in_account_currency:
blank_row = row
blank_row.exchange_rate = 1
if diff > 0:
blank_row.credit_in_account_currency = diff
blank_row.credit = diff
elif diff < 0:
blank_row.debit_in_account_currency = abs(diff)
blank_row.debit = abs(diff)
if not blank_row:
blank_row = self.append(
"accounts",
{
"account": difference_account,
"cost_center": erpnext.get_default_cost_center(self.company),
},
)
blank_row.exchange_rate = 1
if diff > 0:
blank_row.credit_in_account_currency = diff
blank_row.credit = diff
elif diff < 0:
blank_row.debit_in_account_currency = abs(diff)
blank_row.debit = abs(diff)
self.set_total_debit_credit()
self.validate_total_debit_and_credit()
@frappe.whitelist()
def get_outstanding_invoices(self) -> None:
"""Populate the entry with a write-off row per outstanding invoice plus a balancing row."""
def get_outstanding_invoices(self):
self.set("accounts", [])
total = 0
for invoice in self.get_values():
total += flt(invoice.outstanding_amount, self.precision("credit", "accounts"))
self._append_outstanding_invoice_row(invoice)
for d in self.get_values():
total += flt(d.outstanding_amount, self.precision("credit", "accounts"))
jd1 = self.append("accounts", {})
jd1.account = d.account
jd1.party = d.party
balancing_row = self.append("accounts", {})
if self.write_off_based_on == "Accounts Receivable":
jd1.party_type = "Customer"
jd1.credit_in_account_currency = flt(
d.outstanding_amount, self.precision("credit", "accounts")
)
jd1.reference_type = "Sales Invoice"
jd1.reference_name = cstr(d.name)
elif self.write_off_based_on == "Accounts Payable":
jd1.party_type = "Supplier"
jd1.debit_in_account_currency = flt(d.outstanding_amount, self.precision("debit", "accounts"))
jd1.reference_type = "Purchase Invoice"
jd1.reference_name = cstr(d.name)
jd2 = self.append("accounts", {})
if self.write_off_based_on == "Accounts Receivable":
balancing_row.debit_in_account_currency = total
jd2.debit_in_account_currency = total
elif self.write_off_based_on == "Accounts Payable":
balancing_row.credit_in_account_currency = total
jd2.credit_in_account_currency = total
self.validate_total_debit_and_credit()
def _append_outstanding_invoice_row(self, invoice) -> None:
"""Append a party row for a single outstanding invoice per the write-off basis."""
row = self.append("accounts", {})
row.account = invoice.account
row.party = invoice.party
if self.write_off_based_on == "Accounts Receivable":
row.party_type = "Customer"
row.credit_in_account_currency = flt(
invoice.outstanding_amount, self.precision("credit", "accounts")
)
row.reference_type = "Sales Invoice"
row.reference_name = cstr(invoice.name)
elif self.write_off_based_on == "Accounts Payable":
row.party_type = "Supplier"
row.debit_in_account_currency = flt(
invoice.outstanding_amount, self.precision("debit", "accounts")
)
row.reference_type = "Purchase Invoice"
row.reference_name = cstr(invoice.name)
def get_values(self):
if self.write_off_based_on == "Accounts Receivable":
doctype, account_field, party_field = "Sales Invoice", "debit_to", "customer"
elif self.write_off_based_on == "Accounts Payable":
doctype, account_field, party_field = "Purchase Invoice", "credit_to", "supplier"
else:
return
invoice = frappe.qb.DocType(doctype)
query = (
frappe.qb.from_(invoice)
.select(
invoice.name,
invoice[account_field].as_("account"),
invoice[party_field].as_("party"),
invoice.outstanding_amount,
)
.where(
(invoice.docstatus == 1)
& (invoice.company == self.company)
& (invoice.outstanding_amount > 0)
)
cond = (
f" and outstanding_amount <= {flt(self.write_off_amount)}"
if flt(self.write_off_amount) > 0
else ""
)
if flt(self.write_off_amount) > 0:
query = query.where(invoice.outstanding_amount <= flt(self.write_off_amount))
return query.run(as_dict=True)
if self.write_off_based_on == "Accounts Receivable":
return frappe.db.sql(
"""select name, debit_to as account, customer as party, outstanding_amount
from `tabSales Invoice` where docstatus = 1 and company = {}
and outstanding_amount > 0 {}""".format("%s", cond),
self.company,
as_dict=True,
)
elif self.write_off_based_on == "Accounts Payable":
return frappe.db.sql(
"""select name, credit_to as account, supplier as party, outstanding_amount
from `tabPurchase Invoice` where docstatus = 1 and company = {}
and outstanding_amount > 0 {}""".format("%s", cond),
self.company,
as_dict=True,
)
def validate_credit_debit_note(self):
if self.stock_entry:
@@ -1014,7 +962,7 @@ def get_default_bank_cash_account(
account: str | None = None,
*,
fetch_balance: bool = True,
) -> dict:
):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
if mode_of_payment:
@@ -1069,8 +1017,7 @@ def get_against_jv(
start: int,
page_len: int,
filters: dict,
) -> list:
"""Link-field search for submitted Journal Entries having an unreferenced row on an account."""
):
if not frappe.db.has_column("Journal Entry", searchfield):
return []
@@ -1101,97 +1048,67 @@ def get_against_jv(
@frappe.whitelist()
def get_outstanding(
doctype: str | None = None,
docname: str | None = None,
company: str | None = None,
account: str | None = None,
party: str | None = None,
account_currency: str | None = None,
**kwargs,
) -> dict | None:
"""Return the outstanding amount and side to set when referencing a JV / Invoice.
The named parameters are the supported interface. The legacy `args` payload dict
(captured via kwargs) is still accepted for backward compatibility with callers,
including custom apps, and is unpacked into the named parameters below.
"""
def get_outstanding(args: str | dict):
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
if legacy_payload := kwargs.get("args"):
if isinstance(legacy_payload, str):
legacy_payload = json.loads(legacy_payload)
doctype = legacy_payload.get("doctype")
docname = legacy_payload.get("docname")
company = legacy_payload.get("company")
account = legacy_payload.get("account")
party = legacy_payload.get("party")
account_currency = legacy_payload.get("account_currency")
if isinstance(args, str):
args = json.loads(args)
if doctype == "Journal Entry":
return _get_journal_entry_outstanding(docname, account, party)
company_currency = erpnext.get_company_currency(args.get("company"))
due_date = None
if doctype in ("Sales Invoice", "Purchase Invoice"):
return _get_invoice_outstanding(doctype, docname, company, account_currency)
if args.get("doctype") == "Journal Entry":
condition = " and party=%(party)s" if args.get("party") else ""
def _get_journal_entry_outstanding(docname: str, account: str | None, party: str | None) -> dict:
"""Unreferenced debit-minus-credit balance for an account on a Journal Entry."""
jea = frappe.qb.DocType("Journal Entry Account")
query = (
frappe.qb.from_(jea)
.select(Sum(jea.debit_in_account_currency) - Sum(jea.credit_in_account_currency))
.where(
(jea.parent == docname)
& (jea.account == account)
& (jea.reference_type.isnull() | (jea.reference_type == ""))
)
)
if party:
query = query.where(jea.party == party)
result = query.run()
balance = flt(result[0][0]) if result else 0
amount_field = "credit_in_account_currency" if balance > 0 else "debit_in_account_currency"
return {amount_field: abs(balance)}
def _get_invoice_outstanding(doctype: str, docname: str, company: str, account_currency: str | None) -> dict:
"""Outstanding amount, side, party and exchange rate for a Sales/Purchase Invoice."""
party_type = "Customer" if doctype == "Sales Invoice" else "Supplier"
invoice = frappe.db.get_value(
doctype,
docname,
["outstanding_amount", "conversion_rate", scrub(party_type), "due_date"],
as_dict=1,
)
company_currency = erpnext.get_company_currency(company)
exchange_rate = invoice.conversion_rate if account_currency != company_currency else 1
outstanding_is_positive = flt(invoice.outstanding_amount) > 0
if doctype == "Sales Invoice":
amount_field = (
"credit_in_account_currency" if outstanding_is_positive else "debit_in_account_currency"
)
else:
amount_field = (
"debit_in_account_currency" if outstanding_is_positive else "credit_in_account_currency"
against_jv_amount = frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabJournal Entry Account` where parent=%(docname)s and account=%(account)s {condition}
and (reference_type is null or reference_type = '')""",
args,
)
return {
amount_field: abs(flt(invoice.outstanding_amount)),
"exchange_rate": exchange_rate,
"party_type": party_type,
"party": invoice.get(scrub(party_type)),
"reference_due_date": invoice.get("due_date"),
}
against_jv_amount = flt(against_jv_amount[0][0]) if against_jv_amount else 0
amount_field = "credit_in_account_currency" if against_jv_amount > 0 else "debit_in_account_currency"
return {amount_field: abs(against_jv_amount)}
elif args.get("doctype") in ("Sales Invoice", "Purchase Invoice"):
party_type = "Customer" if args.get("doctype") == "Sales Invoice" else "Supplier"
invoice = frappe.db.get_value(
args["doctype"],
args["docname"],
["outstanding_amount", "conversion_rate", scrub(party_type), "due_date"],
as_dict=1,
)
due_date = invoice.get("due_date")
exchange_rate = invoice.conversion_rate if (args.get("account_currency") != company_currency) else 1
if args["doctype"] == "Sales Invoice":
amount_field = (
"credit_in_account_currency"
if flt(invoice.outstanding_amount) > 0
else "debit_in_account_currency"
)
else:
amount_field = (
"debit_in_account_currency"
if flt(invoice.outstanding_amount) > 0
else "credit_in_account_currency"
)
return {
amount_field: abs(flt(invoice.outstanding_amount)),
"exchange_rate": exchange_rate,
"party_type": party_type,
"party": invoice.get(scrub(party_type)),
"reference_due_date": due_date,
}
@frappe.whitelist()
def get_party_account_and_currency(company: str, party_type: str, party: str) -> dict:
"""Return the receivable/payable account for a party and its account currency."""
def get_party_account_and_currency(company: str, party_type: str, party: str):
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
@@ -1211,7 +1128,7 @@ def get_account_details_and_party_type(
debit: float | str | None = None,
credit: float | str | None = None,
exchange_rate: float | str | None = None,
) -> dict:
):
"""Returns dict of account details and party type to be set in Journal Entry on selection of account."""
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
@@ -1269,8 +1186,7 @@ def get_exchange_rate(
debit: float | str | None = None,
credit: float | str | None = None,
exchange_rate: str | float | None = None,
) -> float:
"""Resolve the exchange rate for an account row, by reference, balance or settings."""
):
# Ensure exchange_rate is always numeric to avoid calculation errors
if isinstance(exchange_rate, str):
exchange_rate = flt(exchange_rate) or 1
@@ -1303,3 +1219,14 @@ def get_exchange_rate(
# don't return None or 0 as it is multipled with a value and that value could be lost
return exchange_rate or 1
@frappe.whitelist()
def get_average_exchange_rate(account: str):
exchange_rate = 0
bank_balance_in_account_currency = get_balance_on(account)
if bank_balance_in_account_currency:
bank_balance_in_company_currency = get_balance_on(account, in_account_currency=False)
exchange_rate = bank_balance_in_company_currency / bank_balance_in_account_currency
return exchange_rate

View File

@@ -24,8 +24,7 @@ def get_payment_entry_against_order(
debit_in_account_currency: str | float | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
) -> dict | Document:
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
):
ref_doc = frappe.get_doc(dt, dn)
if flt(ref_doc.per_billed, 2) > 0:
@@ -75,8 +74,7 @@ def get_payment_entry_against_invoice(
debit_in_account_currency: str | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
) -> dict | Document:
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
):
ref_doc = frappe.get_doc(dt, dn)
if dt == "Sales Invoice":
party_type = "Customer"
@@ -112,54 +110,32 @@ def get_payment_entry_against_invoice(
)
def get_payment_entry(ref_doc, args: dict) -> dict | Document:
"""Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row.
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
dict (for client calls).
"""
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
def get_payment_entry(ref_doc, args):
from erpnext.accounts.doctype.journal_entry.journal_entry import (
get_default_bank_cash_account,
get_exchange_rate,
)
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
"Company", ref_doc.company, "cost_center"
)
exchange_rate = _reference_exchange_rate(ref_doc, args)
exchange_rate = 1
if args.get("party_account"):
# Modified to include the posting date for which the exchange rate is required.
# Assumed to be the posting date in the reference document
exchange_rate = get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
args.get("party_account"),
args.get("party_account_currency"),
ref_doc.company,
ref_doc.doctype,
ref_doc.name,
)
party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate)
bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate)
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
if party_row.account_currency != ref_doc.company_currency or (
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
):
je.multi_currency = 1
je.set_amounts_in_company_currency()
je.set_total_debit_credit()
return je if args.get("journal_entry") else je.as_dict()
def _reference_exchange_rate(ref_doc, args: dict) -> float:
"""Exchange rate of the party account on the reference document's posting date."""
if not args.get("party_account"):
return 1
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
return get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
args.get("party_account"),
args.get("party_account_currency"),
ref_doc.company,
ref_doc.doctype,
ref_doc.name,
)
def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the party (debtor/creditor) row that records the advance/payment."""
return je.append(
party_row = je.append(
"accounts",
{
"account": args.get("party_account"),
@@ -177,19 +153,14 @@ def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float
},
)
def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the bank/cash row, defaulting the account and converting the amount to it."""
from erpnext.accounts.doctype.journal_entry.journal_entry import (
get_default_bank_cash_account,
get_exchange_rate,
)
bank_row = je.append("accounts")
# Make it bank_details
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
if bank_account:
bank_row.update(bank_account)
# posting date assumed to be the reference document's posting/transaction date
# Modified to include the posting date for which the exchange rate is required.
# Assumed to be the posting date of the reference date
bank_row.exchange_rate = get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
bank_account["account"],
@@ -200,17 +171,26 @@ def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float)
bank_row.cost_center = cost_center
amount = args.get("debit_in_account_currency") or args.get("amount")
if bank_row.account_currency == args.get("party_account_currency"):
bank_row.set(args.get("amount_field_bank"), amount)
else:
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
return bank_row
# Multi currency check again
if party_row.account_currency != ref_doc.company_currency or (
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
):
je.multi_currency = 1
je.set_amounts_in_company_currency()
je.set_total_debit_credit()
return je if args.get("journal_entry") else je.as_dict()
@frappe.whitelist()
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = voucher_type
journal_entry.company = company
@@ -220,8 +200,7 @@ def make_inter_company_journal_entry(name: str, voucher_type: str, company: str)
@frappe.whitelist()
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None) -> Document:
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None):
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
if existing_reverse:
frappe.throw(
@@ -232,7 +211,7 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | Document | No
from frappe.model.mapper import get_mapped_doc
def post_process(source, target) -> None:
def post_process(source, target):
target.reversal_of = source.name
doclist = get_mapped_doc(

View File

@@ -20,11 +20,10 @@ class AssetService:
Journal Entries tied to asset scrapping or value adjustments.
"""
def __init__(self, doc) -> None:
def __init__(self, doc):
self.doc = doc
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
def validate_depr_account_and_depr_entry_voucher_type(self):
for d in self.doc.get("accounts"):
if d.account_type == "Depreciation":
if self.doc.voucher_type != "Depreciation Entry":
@@ -35,8 +34,7 @@ class AssetService:
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
def has_asset_adjustment_entry(self) -> None:
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
def has_asset_adjustment_entry(self):
if self.doc.flags.get("via_asset_value_adjustment"):
return
@@ -50,13 +48,11 @@ class AssetService:
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
)
def update_asset_value(self) -> None:
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
def update_asset_value(self):
self.update_asset_on_depreciation()
self.update_asset_on_disposal()
def update_asset_on_depreciation(self) -> None:
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
def update_asset_on_depreciation(self):
if self.doc.voucher_type != "Depreciation Entry":
return
@@ -77,8 +73,7 @@ class AssetService:
asset.set_status()
asset.set_total_booked_depreciations()
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
"""Subtract the depreciation amount from the asset's relevant finance book."""
def update_value_after_depreciation(self, asset, depr_amount):
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
@@ -91,8 +86,7 @@ class AssetService:
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
)
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
for d in depr_schedule or []:
if (
@@ -102,8 +96,7 @@ class AssetService:
):
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
def update_asset_on_disposal(self) -> None:
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
def update_asset_on_disposal(self):
if self.doc.voucher_type == "Asset Disposal":
disposed_assets = []
for d in self.doc.get("accounts"):
@@ -124,74 +117,62 @@ class AssetService:
asset_doc.set_status()
disposed_assets.append(d.reference_name)
def unlink_asset_reference(self) -> None:
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
def unlink_asset_reference(self):
for d in self.doc.get("accounts"):
if self._is_depreciation_asset_row(d):
self._reverse_asset_depreciation(d)
if (
self.doc.voucher_type == "Depreciation Entry"
and d.reference_type == "Asset"
and d.reference_name
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
and d.debit
):
asset = frappe.get_doc("Asset", d.reference_name)
if asset.calculate_depreciation:
je_found = False
for fb_row in asset.get("finance_books"):
if je_found:
break
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
for s in depr_schedule or []:
if s.journal_entry == self.doc.name:
s.db_set("journal_entry", None)
fb_row.value_after_depreciation += d.debit
fb_row.db_update()
je_found = True
break
if not je_found:
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
if fb_row.finance_book == self.doc.finance_book:
fb_idx = fb_row.idx
break
fb_row = asset.get("finance_books")[fb_idx - 1]
fb_row.value_after_depreciation += d.debit
fb_row.db_update()
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
asset.set_status()
asset.set_total_booked_depreciations()
elif (
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
):
self._block_scrap_journal_cancel(d)
journal_entry_for_scrap = frappe.db.get_value(
"Asset", d.reference_name, "journal_entry_for_scrap"
)
def _is_depreciation_asset_row(self, d) -> bool:
return bool(
self.doc.voucher_type == "Depreciation Entry"
and d.reference_type == "Asset"
and d.reference_name
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
and d.debit
)
if journal_entry_for_scrap == self.doc.name:
frappe.throw(
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
)
def _reverse_asset_depreciation(self, d) -> None:
"""Add the depreciation amount back to the asset and unlink its schedule row."""
asset = frappe.get_doc("Asset", d.reference_name)
if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit):
self._restore_finance_book_value(asset, d.debit)
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
asset.set_status()
asset.set_total_booked_depreciations()
def _restore_scheduled_depreciation(self, asset, debit: float) -> bool:
"""Unlink this entry from the depreciation schedule and credit back its finance book.
Returns True if a matching scheduled depreciation was found.
"""
for fb_row in asset.get("finance_books"):
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
for s in depr_schedule or []:
if s.journal_entry == self.doc.name:
s.db_set("journal_entry", None)
fb_row.value_after_depreciation += debit
fb_row.db_update()
return True
return False
def _restore_finance_book_value(self, asset, debit: float) -> None:
"""Credit the depreciation amount back to the relevant finance book when no schedule matched."""
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
if fb_row.finance_book == self.doc.finance_book:
fb_idx = fb_row.idx
break
fb_row = asset.get("finance_books")[fb_idx - 1]
fb_row.value_after_depreciation += debit
fb_row.db_update()
def _block_scrap_journal_cancel(self, d) -> None:
"""Prevent cancelling a plain Journal Entry that is an asset's scrap voucher."""
journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap")
if journal_entry_for_scrap == self.doc.name:
frappe.throw(
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
)
def unlink_asset_adjustment_entry(self) -> None:
"""Detach this entry from any Asset Value Adjustment that referenced it."""
def unlink_asset_adjustment_entry(self):
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
(
frappe.qb.update(AssetValueAdjustment)

View File

@@ -18,88 +18,86 @@ class JournalEntryGLComposer(BaseGLComposer):
from the first foreign-currency row (mirroring the former build_gl_map).
"""
def compose(self) -> list:
"""Project the Journal Entry's non-zero account rows into GL dicts."""
self._set_transaction_currency()
def compose(self):
doc = self.doc
gl_map = []
company_currency = erpnext.get_company_currency(doc.company)
doc.transaction_currency = company_currency
doc.transaction_exchange_rate = 1
if doc.multi_currency:
for row in doc.get("accounts"):
if row.account_currency != company_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
advance_doctypes = get_advance_payment_doctypes()
gl_map = []
for d in self.doc.get("accounts"):
if d.debit or d.credit or self.doc.voucher_type == "Exchange Gain Or Loss":
gl_map.append(self.get_gl_dict(self._gl_row(d, advance_doctypes), item=d))
return gl_map
for d in doc.get("accounts"):
if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
r = [d.user_remark, doc.remark]
r = [x for x in r if x]
remarks = "\n".join(r)
def _set_transaction_currency(self) -> None:
"""Company currency, or the first foreign-currency row, becomes the transaction currency."""
doc = self.doc
doc.transaction_currency = erpnext.get_company_currency(doc.company)
doc.transaction_exchange_rate = 1
if not doc.multi_currency:
return
for row in doc.get("accounts"):
if row.account_currency != doc.transaction_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
def _gl_row(self, d, advance_doctypes: list) -> dict:
"""Build the GL dict for a single account row."""
doc = self.doc
remarks = "\n".join(x for x in [d.user_remark, doc.remark] if x)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
return row
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
gl_map.append(
self.get_gl_dict(
row,
item=d,
)
)
return gl_map

View File

@@ -29,11 +29,10 @@ class JournalEntryReferenceValidator:
orders and invoices.
"""
def __init__(self, doc) -> None:
def __init__(self, doc):
self.doc = doc
def validate(self) -> None:
"""Validate every reference-bearing row, then the referenced orders and invoices."""
def validate(self):
self.doc.reference_totals = {}
self.doc.reference_types = {}
self.doc.reference_accounts = {}
@@ -48,24 +47,23 @@ class JournalEntryReferenceValidator:
self._validate_orders()
self._validate_invoices()
def _normalize_reference_fields(self, row) -> None:
def _normalize_reference_fields(self, row):
if not row.reference_type:
row.reference_name = None
if not row.reference_name:
row.reference_type = None
def _has_party_reference(self, row) -> bool:
def _has_party_reference(self, row):
return bool(
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
)
def _reference_amount_field(self, row) -> str:
def _reference_amount_field(self, row):
if row.reference_type in ("Sales Order", "Sales Invoice"):
return "credit_in_account_currency"
return "debit_in_account_currency"
def _validate_order_direction(self, row) -> None:
"""An order can only be linked on the side that records an advance."""
def _validate_order_direction(self, row):
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
frappe.throw(
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
@@ -75,8 +73,7 @@ class JournalEntryReferenceValidator:
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
)
def _register_reference(self, row) -> None:
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
def _register_reference(self, row):
if row.reference_name not in self.doc.reference_totals:
self.doc.reference_totals[row.reference_name] = 0.0
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
@@ -84,8 +81,7 @@ class JournalEntryReferenceValidator:
self.doc.reference_types[row.reference_name] = row.reference_type
self.doc.reference_accounts[row.reference_name] = row.account
def _validate_reference_party_and_account(self, row) -> None:
"""Reject a missing reference, then check party/account against the linked document."""
def _validate_reference_party_and_account(self, row):
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
against_voucher = frappe.db.get_value(
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
@@ -98,7 +94,7 @@ class JournalEntryReferenceValidator:
elif row.reference_type in ("Sales Order", "Purchase Order"):
self._validate_order_party(row, against_voucher)
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields):
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
if self.doc.voucher_type == "Exchange Gain Or Loss":
return
@@ -109,9 +105,7 @@ class JournalEntryReferenceValidator:
)
)
def _resolve_invoice_party_account(self, row, against_voucher) -> tuple:
"""Expected (party_account, party) for an invoice row, honouring deferred booking
and invoice-discounting accounts."""
def _resolve_invoice_party_account(self, row, against_voucher):
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
debit_or_credit = "Debit" if row.debit else "Credit"
party_account = get_deferred_booking_accounts(
@@ -126,7 +120,7 @@ class JournalEntryReferenceValidator:
party_account = against_voucher[1]
return party_account, against_voucher[0]
def _validate_order_party(self, row, against_voucher) -> None:
def _validate_order_party(self, row, against_voucher):
if against_voucher != row.party:
frappe.throw(
_("Row {0}: {1} {2} does not match with {3}").format(
@@ -134,8 +128,8 @@ class JournalEntryReferenceValidator:
)
)
def _validate_orders(self) -> None:
"""Validate totals, closed and docstatus for referenced orders."""
def _validate_orders(self):
"""Validate totals, closed and docstatus for orders"""
for reference_name, total in self.doc.reference_totals.items():
reference_type = self.doc.reference_types[reference_name]
account = self.doc.reference_accounts[reference_name]
@@ -146,7 +140,7 @@ class JournalEntryReferenceValidator:
self._validate_order_status(order, reference_type, reference_name)
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
def _validate_order_status(self, order, reference_type, reference_name) -> None:
def _validate_order_status(self, order, reference_type, reference_name):
if order.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
if flt(order.per_billed) >= 100:
@@ -154,8 +148,7 @@ class JournalEntryReferenceValidator:
if cstr(order.status) == "Closed":
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name) -> None:
"""The advance paid against an order cannot exceed its grand total."""
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name):
account_currency = get_account_currency(account)
if account_currency == self.doc.company_currency:
voucher_total = order.base_grand_total
@@ -174,8 +167,8 @@ class JournalEntryReferenceValidator:
)
)
def _validate_invoices(self) -> None:
"""Validate totals and docstatus for referenced invoices."""
def _validate_invoices(self):
"""Validate totals and docstatus for invoices"""
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
return
for reference_name, total in self.doc.reference_totals.items():
@@ -185,8 +178,7 @@ class JournalEntryReferenceValidator:
invoice = frappe.get_doc(reference_type, reference_name)
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
"""Payment booked against an invoice cannot exceed its outstanding amount."""
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name):
if invoice.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))

View File

@@ -169,11 +169,8 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency",
"credit",
"credit_in_account_currency",
"debit_in_transaction_currency",
"credit_in_transaction_currency",
]
# Transaction currency is USD (first foreign row); the INR row is converted at 1/50.
self.expected_gle = [
{
"account": "_Test Bank - _TC",
@@ -182,8 +179,6 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 0,
"credit": 5000,
"credit_in_account_currency": 5000,
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
{
"account": "_Test Bank USD - _TC",
@@ -192,8 +187,6 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 100,
"credit": 0,
"credit_in_account_currency": 0,
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
]
@@ -210,52 +203,6 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertFalse(gle)
def test_multi_currency_transaction_currency_on_foreign_debit(self):
"""Pin debit_in_transaction_currency for a foreign-currency debit row.
Transaction currency is USD (the first foreign row); the INR debit row must be
converted at 1/exchange_rate, so 5000 INR -> 100 USD. Guards the / vs * direction.
"""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.multi_currency = 1
jv.append(
"accounts",
{
"account": "_Test Bank USD - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 100,
"exchange_rate": 50,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": 5000,
"exchange_rate": 1,
},
)
jv.submit()
self.voucher_no = jv.name
self.fields = ["account", "debit_in_transaction_currency", "credit_in_transaction_currency"]
self.expected_gle = [
{
"account": "_Test Bank - _TC",
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
{
"account": "_Test Bank USD - _TC",
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
]
self.check_gl_entries()
def test_reverse_journal_entry(self):
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
@@ -741,95 +688,6 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
def test_get_balance_places_difference_on_blank_row(self):
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"debit_in_account_currency": 100,
"debit": 100,
"exchange_rate": 1,
},
)
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1}) # amountless row
jv.set_total_debit_credit()
self.assertEqual(jv.difference, 100)
jv.get_balance()
blank_row = jv.accounts[1]
self.assertEqual(blank_row.credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
def test_get_outstanding_invoices_builds_write_off_rows(self):
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.voucher_type = "Write Off Entry"
jv.write_off_based_on = "Accounts Receivable"
jv.write_off_amount = 1000
jv.get_outstanding_invoices()
invoice_rows = [row for row in jv.accounts if row.reference_name == invoice.name]
self.assertTrue(invoice_rows)
self.assertEqual(invoice_rows[0].party_type, "Customer")
self.assertEqual(invoice_rows[0].reference_type, "Sales Invoice")
self.assertEqual(flt(invoice_rows[0].credit_in_account_currency), 700)
def test_unlink_advance_entry_reference_on_cancel(self):
"""Characterize: cancelling an advance JE against an invoice clears the row's reference."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
advance_row = jv.accounts[1]
advance_row.party_type = "Customer"
advance_row.party = "_Test Customer"
advance_row.is_advance = "Yes"
advance_row.reference_type = "Sales Invoice"
advance_row.reference_name = invoice.name
jv.submit()
jv.cancel()
jv.reload()
self.assertFalse(jv.accounts[1].reference_type)
self.assertFalse(jv.accounts[1].reference_name)
def test_get_payment_entry_against_order_builds_advance_je(self):
"""Characterize the mapper: an advance Bank Entry JE is built against an unbilled order."""
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order()
je = get_payment_entry_against_order("Sales Order", sales_order.name, journal_entry=True)
self.assertEqual(je.voucher_type, "Bank Entry")
party_rows = [row for row in je.accounts if row.party_type == "Customer"]
self.assertTrue(party_rows)
self.assertEqual(party_rows[0].reference_type, "Sales Order")
self.assertEqual(party_rows[0].reference_name, sales_order.name)
self.assertEqual(party_rows[0].is_advance, "Yes")
def test_make_inter_company_journal_entry_builds_linked_draft(self):
"""Characterize the mapper: the counterpart JE carries the company and back-reference."""
from erpnext.accounts.doctype.journal_entry.mapper import make_inter_company_journal_entry
source = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
result = make_inter_company_journal_entry(
source.name, "Inter Company Journal Entry", "_Test Company 1"
)
self.assertEqual(result.get("voucher_type"), "Inter Company Journal Entry")
self.assertEqual(result.get("company"), "_Test Company 1")
self.assertEqual(result.get("inter_company_journal_entry_reference"), source.name)
def make_journal_entry(
account1,

View File

@@ -614,12 +614,10 @@
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately.",
"fieldname": "update_stock",
"fieldtype": "Check",
"label": "Update Stock",
"print_hide": 1,
"show_description_on_click": 1
"print_hide": 1
},
{
"fieldname": "scan_barcode",
@@ -1692,7 +1690,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-06-13 18:36:46.704623",
"modified": "2026-05-28 12:36:55.215363",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -51,6 +51,16 @@ class ExpenseAccountService:
if doc.update_stock and item.warehouse and (not item.from_warehouse):
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]:
msg = _(
"Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account"
).format(
item.idx,
frappe.bold(_inv_dict["account"]),
frappe.bold(item.expense_account),
frappe.bold(item.warehouse),
)
frappe.msgprint(msg, title=_("Expense Head Changed"))
item.expense_account = _inv_dict["account"]
else:
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not

View File

@@ -158,7 +158,6 @@ def start_repost(account_repost_doc: str | None = None) -> None:
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
repost_doc.check_permission("write")
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting

View File

@@ -715,7 +715,6 @@
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately.",
"fieldname": "update_stock",
"fieldtype": "Check",
"hide_days": 1,
@@ -723,8 +722,7 @@
"label": "Update Stock",
"oldfieldname": "update_stock",
"oldfieldtype": "Check",
"print_hide": 1,
"show_description_on_click": 1
"print_hide": 1
},
{
"fieldname": "scan_barcode",

View File

@@ -511,7 +511,6 @@ def get_party_advance_account(party_type, party, company):
@frappe.whitelist()
def get_party_bank_account(party_type: str, party: str):
frappe.has_permission("Bank Account", "read", throw=True)
return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1})

View File

@@ -927,28 +927,8 @@ class ReceivablePayableReport:
if self.filters.project:
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
self.add_user_permission_filters()
self.add_accounting_dimensions_filters()
def add_user_permission_filters(self):
# Party is a dynamic link, so match conditions cannot auto-apply Customer/Supplier user permissions
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
from frappe.permissions import get_allowed_docs_for_doctype
user_permissions = get_user_permissions()
if not user_permissions:
return
for party_type in self.party_type:
if party_type not in user_permissions:
continue
allowed_parties = get_allowed_docs_for_doctype(user_permissions[party_type], party_type)
self.qb_selection_filter.append(
(self.ple.party_type != party_type) | self.ple.party.isin(allowed_parties or [""])
)
def get_cost_center_conditions(self):
cost_center_list = get_cost_centers_with_children(self.filters.cost_center)
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))

View File

@@ -1243,44 +1243,3 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])
def test_accounts_receivable_respects_user_permissions(self):
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
# must be applied explicitly. The report should only show permitted customers.
original_customer = self.customer
second_customer = "_Test AR Perm Customer"
# create_customer overrides self.customer, so build the restricted invoice first
self.create_customer(customer_name=second_customer)
self.create_sales_invoice(no_payment_schedule=True)
self.customer = original_customer
allowed_invoice = self.create_sales_invoice(no_payment_schedule=True)
test_user = "test_ar_user_permission@example.com"
if not frappe.db.exists("User", test_user):
user = frappe.new_doc("User")
user.email = test_user
user.first_name = "AR Perm"
user.append("roles", {"role": "Accounts User"})
user.save()
frappe.permissions.add_user_permission("Customer", original_customer, test_user)
filters = {
"company": self.company,
"party_type": "Customer",
"report_date": today(),
"range": "30, 60, 90, 120",
}
frappe.set_user(test_user)
try:
report = execute(filters)
finally:
frappe.set_user("Administrator")
parties = {row.party for row in report[1]}
self.assertIn(original_customer, parties)
self.assertNotIn(second_customer, parties)
self.assertEqual(allowed_invoice.customer, original_customer)

View File

@@ -2384,7 +2384,7 @@ class QueryPaymentLedger:
.where(Criterion.all(self.common_filter))
.where(Criterion.all(self.dimensions_filter))
.where(Criterion.all(self.voucher_posting_date))
.groupby(ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
.groupby(ple.account, ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
)
# build query for voucher outstanding
@@ -2405,7 +2405,7 @@ class QueryPaymentLedger:
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_against_voucher_no))
.where(Criterion.all(self.common_filter))
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
.groupby(ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
)
# build CTE for combining voucher amount and outstanding

View File

@@ -598,7 +598,6 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
target_doc.so_detail = source_doc.so_detail
target_doc.expense_account = source_doc.expense_account
target_doc.dn_detail = source_doc.name
target_doc.cost_center = source_doc.cost_center
if default_warehouse_for_sales_return:
target_doc.warehouse = default_warehouse_for_sales_return
elif doctype == "Sales Invoice" or doctype == "POS Invoice":

View File

@@ -425,12 +425,7 @@ class SellingController(StockController):
row.new_item_code
for row in frappe.get_all(
"Product Bundle",
filters={
"new_item_code": ("in", items_to_fetch),
"is_active": 1,
"docstatus": 1,
"disabled": 0,
},
filters={"new_item_code": ("in", items_to_fetch), "is_active": 1, "docstatus": 1},
fields="new_item_code",
)
}
@@ -984,14 +979,9 @@ class SellingController(StockController):
qty_can_be_deliver = 0
if sre_doc.reservation_based_on == "Serial and Batch":
# Delivered serial/batch may live in a Serial and Batch Bundle or directly in the
# row's serial_no/batch_no fields (use_serial_batch_fields). Read from whichever is
# present so this never crashes on a missing bundle.
(
delivered_serial_nos,
delivered_batch_qty,
) = get_delivered_serial_batch_for_reservation(item)
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
if sre_doc.has_serial_no:
delivered_serial_nos = [d.serial_no for d in sbb.entries]
for entry in sre_doc.sb_entries:
if entry.serial_no in delivered_serial_nos:
entry.delivered_qty = 1 # Qty will always be 0 or 1 for Serial No.
@@ -999,16 +989,16 @@ class SellingController(StockController):
qty_can_be_deliver += 1
delivered_serial_nos.remove(entry.serial_no)
else:
delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries}
for entry in sre_doc.sb_entries:
available_batch_qty = delivered_batch_qty.get(entry.batch_no, 0)
if available_batch_qty > 0:
if entry.batch_no in delivered_batch_qty:
delivered_qty = min(
(entry.qty - entry.delivered_qty), available_batch_qty
(entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no]
)
entry.delivered_qty += delivered_qty
entry.db_update()
qty_can_be_deliver += delivered_qty
delivered_batch_qty[entry.batch_no] = available_batch_qty - delivered_qty
delivered_batch_qty[entry.batch_no] -= delivered_qty
else:
# `Delivered Qty` should be less than or equal to `Reserved Qty`.
qty_can_be_deliver = min(
@@ -1184,31 +1174,3 @@ def get_serial_and_batch_bundle(child, parent, delivery_note_child=None):
child.db_set("serial_and_batch_bundle", doc.name)
return doc.name
def get_delivered_serial_batch_for_reservation(item):
"""Serial nos and per-batch qty delivered by a stock row.
The detail may be stored in a Serial and Batch Bundle or directly in the row's
``serial_no``/``batch_no`` fields (``use_serial_batch_fields``). Reading from whichever is
present keeps the Stock Reservation Entry delivered-qty update independent of a bundle being
created -- delivering reserved serial/batch stock used to crash when the row had no bundle.
"""
serial_nos, batch_qty = [], {}
if item.get("serial_and_batch_bundle"):
bundle = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
for row in bundle.entries:
if row.serial_no:
serial_nos.append(row.serial_no)
if row.batch_no:
batch_qty[row.batch_no] = batch_qty.get(row.batch_no, 0) + abs(flt(row.qty))
else:
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
if item.get("serial_no"):
serial_nos = get_serial_nos(item.serial_no)
if item.get("batch_no"):
batch_qty[item.batch_no] = abs(flt(item.get("stock_qty") or item.get("qty")))
return serial_nos, batch_qty

View File

@@ -14,7 +14,6 @@
"opportunity_section",
"close_opportunity_after_days",
"column_break_9",
"enable_opportunity_creation_from_contact_us",
"quotation_section",
"default_valid_till",
"section_break_13",
@@ -99,20 +98,15 @@
"fieldname": "update_timestamp_on_new_communication",
"fieldtype": "Check",
"label": "Update timestamp on new communication"
},
{
"default": "0",
"fieldname": "enable_opportunity_creation_from_contact_us",
"fieldtype": "Check",
"label": "Enable Opportunity Creation from Contact Us"
}
],
"grid_page_length": 50,
"hide_toolbar": 0,
"icon": "fa fa-cog",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-11 23:09:49.750381",
"modified": "2026-03-16 13:28:19.573964",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",

View File

@@ -2,7 +2,6 @@
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
@@ -21,20 +20,8 @@ class CRMSettings(Document):
carry_forward_communication_and_comments: DF.Check
close_opportunity_after_days: DF.Int
default_valid_till: DF.Data | None
enable_opportunity_creation_from_contact_us: DF.Check
update_timestamp_on_new_communication: DF.Check
# end: auto-generated types
def validate(self):
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
self.validate_enable_opportunity_creation_from_contact_us()
def validate_enable_opportunity_creation_from_contact_us(self):
contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled")
if self.enable_opportunity_creation_from_contact_us and contact_disabled:
frappe.throw(
_(
"Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
)
)

View File

@@ -8,7 +8,7 @@ from frappe.contacts.address_and_contact import (
load_address_and_contact,
)
from frappe.model.document import Document
from frappe.utils import comma_and, get_link_to_form, validate_email_address
from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
from frappe.utils.data import DateTimeLikeObject
from erpnext.accounts.party import set_taxes
@@ -173,6 +173,9 @@ class Lead(SellingController, CRMNote):
if self.email_id == self.lead_owner:
frappe.throw(_("Lead Owner cannot be same as the Lead Email Address"))
if self.is_new() or not self.image:
self.image = has_gravatar(self.email_id)
def link_to_contact(self):
# update contact links
if self.contact_doc:

View File

@@ -130,6 +130,7 @@ def make_lead_from_communication(communication: str, ignore_communication_links:
}
)
lead.flags.ignore_mandatory = True
lead.flags.ignore_permissions = True
lead.insert()
lead_name = lead.name

View File

@@ -145,7 +145,7 @@ def make_opportunity_from_communication(
"opportunity_from": opportunity_from,
"party_name": lead,
}
).insert()
).insert(ignore_permissions=True)
link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)

View File

@@ -5,11 +5,6 @@ from frappe.utils import cstr, now, today
from pypika import functions
def disable_opportunity_creation_on_contact_us_disabled(doc, method):
if doc.is_disabled:
frappe.db.set_single_value("CRM Settings", "enable_opportunity_creation_from_contact_us", 0)
def update_lead_phone_numbers(contact, method):
if contact.phone_nos:
contact_lead = contact.get_link_for("Lead")

View File

@@ -383,9 +383,6 @@ doc_events = {
"Event": {
"after_insert": "erpnext.crm.utils.link_events_with_prospect",
},
"Contact Us Settings": {
"on_update": "erpnext.crm.utils.disable_opportunity_creation_on_contact_us_disabled",
},
"Sales Invoice": {
"on_submit": [
"erpnext.regional.italy.utils.sales_invoice_on_submit",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -175,9 +175,6 @@ frappe.ui.form.on("BOM", {
with_operations: function (frm) {
frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0);
frm.trigger("toggle_fields_for_semi_finished_goods");
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
frm.trigger("routing");
}
},
fg_based_operating_cost: function (frm) {
@@ -586,7 +583,7 @@ frappe.ui.form.on("BOM", {
},
routing(frm) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
frappe.call({
doc: frm.doc,
method: "get_routing",

View File

@@ -245,14 +245,10 @@ class BOMCreator(Document):
frappe.throw(_("Please set {0} in BOM Creator {1}").format(_(label), self.name))
def on_submit(self):
self.enqueue_bom_creation()
self.enqueue_create_boms()
@frappe.whitelist()
def enqueue_create_boms(self):
self.check_permission("submit")
self.enqueue_bom_creation()
def enqueue_bom_creation(self):
frappe.enqueue(
self.create_boms,
queue="short",
@@ -386,31 +382,6 @@ class BOMCreator(Document):
production_item_wise_rm[(row.item_code, row.name)].bom_no = bom.name
@frappe.whitelist()
def edit_bom_creator(self, docname: str, data: str | dict):
if not frappe.db.exists("BOM Creator Item", {"parent": self.name, "name": docname}):
frappe.throw(_("BOM Creator Item with name {0} does not exist").format(docname))
if isinstance(data, str):
data = frappe.parse_json(data)
updated = False
for row in self.items:
if row.name == docname:
for key, value in data.items():
if key in BOM_ITEM_FIELDS and row.get(key) != value:
row.set(key, value)
updated = True
break
if updated:
self.set_rate_for_items()
self.save()
frappe.msgprint(_("Updated successfully"), alert=True)
return self
def has_operations(self):
for row in self.items:
if row.operation:
@@ -422,144 +393,9 @@ class BOMCreator(Document):
def get_default_bom(self, item_code: str):
return frappe.get_cached_value("Item", item_code, "default_bom")
@frappe.whitelist()
def add_item(self, **kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
item_info = get_item_details(kwargs.item_code)
parent_row_no = ""
if kwargs.fg_reference_id and self.name != kwargs.fg_reference_id:
parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
kwargs.update(
{
"uom": item_info.stock_uom,
"stock_uom": item_info.stock_uom,
"conversion_factor": 1,
}
)
if parent_row_no:
kwargs.update({"parent_row_no": parent_row_no})
self.append("items", kwargs)
self.save()
return self
@frappe.whitelist()
def add_sub_assembly(self, **kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
bom_item = frappe.parse_json(kwargs.bom_item)
name = kwargs.fg_reference_id
parent_row_no = ""
if not kwargs.convert_to_sub_assembly:
item_info = get_item_details(bom_item.item_code)
parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
item_row = self.append(
"items",
{
"item_code": bom_item.item_code,
"qty": bom_item.qty,
"uom": item_info.stock_uom,
"fg_item": kwargs.fg_item,
"conversion_factor": 1,
"parent_row_no": parent_row_no,
"fg_reference_id": name,
"stock_qty": bom_item.qty,
"do_not_explode": 1,
"is_expandable": 1,
"stock_uom": item_info.stock_uom,
"operation": bom_item.operation,
"is_phantom_item": sbool(kwargs.phantom),
},
)
parent_row_no = item_row.idx
name = ""
else:
if sbool(kwargs.phantom):
parent_row = next(item for item in self.items if item.name == kwargs.fg_reference_id)
parent_row.is_phantom_item = 1
parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
for row in bom_item.get("items"):
row = frappe._dict(row)
item_info = get_item_details(row.item_code)
self.append(
"items",
{
"item_code": row.item_code,
"qty": row.qty,
"operation": row.operation,
"fg_item": bom_item.item_code,
"uom": item_info.stock_uom,
"fg_reference_id": name,
"parent_row_no": parent_row_no,
"conversion_factor": 1,
"do_not_explode": 1,
"stock_qty": row.qty,
"stock_uom": item_info.stock_uom,
},
)
self.save()
return self
@frappe.whitelist()
def delete_node(self, **kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
updated = False
if kwargs.docname:
row = next((row for row in self.items if row.name == kwargs.docname), None)
if not row:
frappe.throw(_("BOM Creator Item with name {0} does not exist").format(kwargs.docname))
row.delete()
updated = True
items = get_children(parent=kwargs.fg_item, parent_id=self.name)
if items:
for item in items:
updated = True
child_row = next((row for row in self.items if row.name == item.name), None)
if child_row:
child_row.delete()
if item.expandable:
self.delete_node(fg_item=item.value)
if updated:
self.set_rate_for_items()
self.save()
return self
return frappe._dict()
@frappe.whitelist()
def get_children(doctype: str | None = None, parent: str | None = None, **kwargs):
frappe.has_permission("BOM Creator", "read", throw=True)
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
@@ -593,6 +429,108 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
return frappe.get_all("BOM Creator Item", fields=fields, filters=query_filters, order_by="idx")
@frappe.whitelist()
def add_item(**kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
doc = frappe.get_doc("BOM Creator", kwargs.parent)
item_info = get_item_details(kwargs.item_code)
parent_row_no = ""
if kwargs.fg_reference_id and doc.name != kwargs.fg_reference_id:
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
kwargs.update(
{
"uom": item_info.stock_uom,
"stock_uom": item_info.stock_uom,
"conversion_factor": 1,
}
)
if parent_row_no:
kwargs.update({"parent_row_no": parent_row_no})
doc.append("items", kwargs)
doc.save()
return doc
@frappe.whitelist()
def add_sub_assembly(**kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
doc = frappe.get_doc("BOM Creator", kwargs.parent)
bom_item = frappe.parse_json(kwargs.bom_item)
name = kwargs.fg_reference_id
parent_row_no = ""
if not kwargs.convert_to_sub_assembly:
item_info = get_item_details(bom_item.item_code)
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
item_row = doc.append(
"items",
{
"item_code": bom_item.item_code,
"qty": bom_item.qty,
"uom": item_info.stock_uom,
"fg_item": kwargs.fg_item,
"conversion_factor": 1,
"parent_row_no": parent_row_no,
"fg_reference_id": name,
"stock_qty": bom_item.qty,
"do_not_explode": 1,
"is_expandable": 1,
"stock_uom": item_info.stock_uom,
"operation": bom_item.operation,
"is_phantom_item": sbool(kwargs.phantom),
},
)
parent_row_no = item_row.idx
name = ""
else:
if sbool(kwargs.phantom):
parent_row = next(item for item in doc.items if item.name == kwargs.fg_reference_id)
parent_row.db_set("is_phantom_item", 1)
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
for row in bom_item.get("items"):
row = frappe._dict(row)
item_info = get_item_details(row.item_code)
doc.append(
"items",
{
"item_code": row.item_code,
"qty": row.qty,
"operation": row.operation,
"fg_item": bom_item.item_code,
"uom": item_info.stock_uom,
"fg_reference_id": name,
"parent_row_no": parent_row_no,
"conversion_factor": 1,
"do_not_explode": 1,
"stock_qty": row.qty,
"stock_uom": item_info.stock_uom,
},
)
doc.save()
return doc
def get_item_details(item_code):
return frappe.get_cached_value(
"Item", item_code, ["item_name", "description", "image", "stock_uom", "default_bom"], as_dict=1
@@ -610,3 +548,46 @@ def get_parent_row_no(doc, name):
frappe.msgprint(_("Parent Row No not found for {0}").format(name), alert=True)
return None
@frappe.whitelist()
def delete_node(**kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
items = get_children(parent=kwargs.fg_item, parent_id=kwargs.parent)
if kwargs.docname:
frappe.delete_doc("BOM Creator Item", kwargs.docname)
for item in items:
frappe.delete_doc("BOM Creator Item", item.name)
if item.expandable:
delete_node(fg_item=item.value, parent=item.parent_id)
doc = frappe.get_doc("BOM Creator", kwargs.parent)
doc.set_rate_for_items()
doc.save()
return doc
@frappe.whitelist()
def edit_bom_creator(doctype: str, docname: str, data: str | dict, parent: str):
if not frappe.has_permission(doctype=doctype, ptype="write", parent_doctype="BOM Creator"):
frappe.throw(_("You do not have permission to edit this document"), frappe.PermissionError)
if isinstance(data, str):
data = frappe.parse_json(data)
frappe.db.set_value(doctype, docname, data)
doc = frappe.get_doc("BOM Creator", parent)
doc.set_rate_for_items()
doc.save()
frappe.msgprint(_("Updated successfully"), alert=True)
return doc

View File

@@ -5,6 +5,10 @@ import random
import frappe
from erpnext.manufacturing.doctype.bom_creator.bom_creator import (
add_item,
add_sub_assembly,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
@@ -34,7 +38,8 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
doc.add_sub_assembly(
add_sub_assembly(
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.name,
bom_item={
@@ -88,7 +93,8 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
doc.add_item(
add_item(
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -131,7 +137,8 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
doc.add_item(
add_item(
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -141,8 +148,9 @@ class TestBOMCreator(ERPNextTestSuite):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
doc.add_sub_assembly(
add_sub_assembly(
convert_to_sub_assembly=1,
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={
@@ -197,7 +205,8 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
doc.add_item(
add_item(
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -207,8 +216,9 @@ class TestBOMCreator(ERPNextTestSuite):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
doc.add_sub_assembly(
add_sub_assembly(
convert_to_sub_assembly=1,
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={
@@ -241,43 +251,6 @@ class TestBOMCreator(ERPNextTestSuite):
data = frappe.get_all("BOM", filters={"bom_creator": doc.name, "docstatus": 1})
self.assertEqual(len(data), 2)
def test_edit_and_delete_reject_unknown_item(self):
final_product = "Bicycle"
make_item(
final_product,
{
"item_group": "Raw Material",
"stock_uom": "Nos",
},
)
doc = make_bom_creator(
name="Bicycle BOM Guarded",
company="_Test Company",
item_code=final_product,
qty=1,
rm_cosy_as_per="Valuation Rate",
currency="INR",
plc_conversion_rate=1,
conversion_rate=1,
)
# Editing a row that does not belong to this BOM Creator must be rejected.
self.assertRaises(
frappe.ValidationError,
doc.edit_bom_creator,
docname="non-existent-row",
data={"qty": 5},
)
# Deleting a row that does not belong to this BOM Creator must be rejected.
self.assertRaises(
frappe.ValidationError,
doc.delete_node,
fg_item=final_product,
docname="non-existent-row",
)
def create_items():
raw_materials = [

View File

@@ -116,57 +116,17 @@ def _sub_assembly_reserved_filter(table, child, item_code, warehouse):
)
class ProductionPlanStockReservation:
"""Reservation lifecycle for a Production Plan.
A Production Plan reserves stock for two of its child tables: the sub-assembly
items it will manufacture and the raw materials of its material-request rows
(see ``_RESERVATION_TABLES``). On submit the rows are reserved; on cancel the
reservations are released.
The reserved-qty *query* helpers in this module
(``get_reserved_qty_for_production_plan`` etc.) are read-only and answer "how
much is reserved?" for bins and reports, so they stay module-level functions
rather than methods, mirroring the engine's own query helpers.
"""
def __init__(self, doc):
self.doc = doc
def reserve(self, items: str | list | None = None, table_name: str | None = None, notify: bool = False):
"""Reserve (docstatus 1) or release (docstatus 2) stock for the plan's tables."""
if items and isinstance(items, str):
items = parse_json(items)
for child_table_name, kwargs in _RESERVATION_TABLES.items():
if table_name and table_name != child_table_name:
continue
self._reserve_or_cancel_plan_table(items, kwargs)
self.doc.reload()
def _reserve_or_cancel_plan_table(self, items, kwargs):
sre = StockReservation(self.doc, items=items, kwargs=kwargs)
if self.doc.docstatus == 1:
if sre.make_stock_reservation_entries():
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
elif self.doc.docstatus == 2:
sre.cancel_stock_reservation_entries()
def cancel(self, sre_list: str | list | None = None):
"""Cancel specific (or all) Stock Reservation Entries held by the plan."""
StockReservation(self.doc).cancel_stock_reservation_entries(sre_list)
self.doc.reload()
@frappe.whitelist()
def make_stock_reservation_entries(
doc: str | Document, items: str | list | None = None, table_name: str | None = None, notify: bool = False
):
"""Whitelisted entry point: verify Production Plan write access, then reserve stock."""
doc = _load_production_plan(doc)
if isinstance(doc, str):
doc = parse_json(doc)
doc = frappe.get_doc("Production Plan", doc.get("name"))
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
ProductionPlanStockReservation(doc).reserve(items=items, table_name=table_name, notify=notify)
reserve_stock_for_production_plan(doc, items=items, table_name=table_name, notify=notify)
def reserve_stock_for_production_plan(
@@ -174,19 +134,35 @@ def reserve_stock_for_production_plan(
):
"""Reserve stock for a Production Plan. Internal: no permission check (also called
from the Production Plan submit/cancel lifecycle)."""
ProductionPlanStockReservation(doc).reserve(items=items, table_name=table_name, notify=notify)
if items and isinstance(items, str):
items = parse_json(items)
for child_table_name, kwargs in _RESERVATION_TABLES.items():
if table_name and table_name != child_table_name:
continue
_reserve_or_cancel_plan_table(doc, items, kwargs)
doc.reload()
def _reserve_or_cancel_plan_table(doc, items, kwargs):
sre = StockReservation(doc, items=items, kwargs=kwargs)
if doc.docstatus == 1:
if sre.make_stock_reservation_entries():
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
elif doc.docstatus == 2:
sre.cancel_stock_reservation_entries()
@frappe.whitelist()
def cancel_stock_reservation_entries(doc: str | Document, sre_list: str | list):
"""Whitelisted entry point: verify Production Plan write access, then cancel reservations."""
doc = _load_production_plan(doc)
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
ProductionPlanStockReservation(doc).cancel(sre_list)
def _load_production_plan(doc: str | Document) -> Document:
if isinstance(doc, str):
doc = parse_json(doc)
doc = frappe.get_doc("Production Plan", doc.get("name"))
return doc
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
sre = StockReservation(doc)
sre.cancel_stock_reservation_entries(sre_list)
doc.reload()

View File

@@ -2322,74 +2322,6 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(len(reserved_entries), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_restored_on_work_order_cancel(self):
# Spec #5 (cancellation path): when a Work Order created from a Production Plan is cancelled,
# the reservation that was transferred PP -> WO must flow back to the still-open Production
# Plan, not silently vanish.
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
try:
bom_tree = {
"FG For SR Cancel": {"Sub Assembly For SR Cancel 1": {"Raw Material For SR Cancel 1": {}}}
}
parent_bom = create_nested_bom(bom_tree, prefix="")
warehouse = "_Test Warehouse - _TC"
# Plenty of stock so the Production Plan reserves everything directly on submit.
for item_code in ["Sub Assembly For SR Cancel 1", "Raw Material For SR Cancel 1"]:
make_stock_entry(item_code=item_code, target=warehouse, qty=20, basic_rate=100)
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=10,
skip_available_sub_assembly_item=1,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
sub_assembly_warehouse=warehouse,
for_warehouse=warehouse,
reserve_stock=1,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
plan.submit()
def pp_reserved():
return sum(
r.reserved_qty
for r in StockReservation(plan).get_reserved_entries("Production Plan", plan.name)
)
reserved_before = pp_reserved()
self.assertGreater(reserved_before, 0, "Production Plan should reserve stock on submit")
plan.make_work_order()
work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name")
work_orders = list(set(work_orders))
for wo_name in work_orders:
wo_doc = frappe.get_doc("Work Order", wo_name)
wo_doc.source_warehouse = warehouse
wo_doc.wip_warehouse = warehouse
wo_doc.fg_warehouse = warehouse
wo_doc.submit()
# After all Work Orders are submitted the reservation has fully transferred off the plan.
self.assertEqual(pp_reserved(), 0, "Reservation should transfer PP -> WO on submit")
# Cancelling the Work Orders must return the reservation to the Production Plan.
for wo_name in work_orders:
frappe.get_doc("Work Order", wo_name).cancel()
self.assertEqual(
pp_reserved(), reserved_before, "Cancelling the Work Order must restore the PP reservation"
)
finally:
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_of_serial_nos_against_production_plan(self):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom

View File

@@ -14,8 +14,8 @@ from pypika import functions as fn
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
from erpnext.manufacturing.doctype.work_order.mapper import check_if_scrap_warehouse_mandatory
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
get_consumed_qty,
get_row_wise_serial_batch,
)
@@ -44,7 +44,7 @@ class RequiredItemsService:
# update in bin
self.update_reserved_qty_for_production()
WorkOrderStockReservation(self.doc).validate_reserved_qty()
StockReservationService(self.doc).validate_reserved_qty()
def update_reserved_qty_for_production(self, items=None):
"""update reserved_qty_for_production in bins"""
@@ -142,7 +142,7 @@ class RequiredItemsService:
transferred_qty = transferred_items.get(row.item_code) or 0.0
row.db_set("transferred_qty", transferred_qty, update_modified=False)
if self.doc.reserve_stock:
WorkOrderStockReservation(self.doc).update_qty_in_stock_reservation(
StockReservationService(self.doc).update_qty_in_stock_reservation(
row, transferred_qty, row_wise_serial_batch
)
@@ -189,7 +189,7 @@ class RequiredItemsService:
continue
warehouse = wip_warehouse or item.source_warehouse
WorkOrderStockReservation(self.doc).update_consumed_qty_in_stock_reservation(
StockReservationService(self.doc).update_consumed_qty_in_stock_reservation(
item, consumed_qty, warehouse
)

View File

@@ -3,7 +3,7 @@
"""Stock reservation logic for Work Order.
Extracted from work_order.py. ``WorkOrderStockReservation`` wraps a Work Order
Extracted from work_order.py. ``StockReservationService`` wraps a Work Order
document (composition) and owns the reservation-related behaviour; the
module-level helpers are reused by the controller and by Production Plan.
work_order.py re-exports them to preserve whitelist dotted-paths and imports.
@@ -51,7 +51,7 @@ _SERIAL_BATCH_FIELDS = [
]
class WorkOrderStockReservation:
class StockReservationService:
def __init__(self, doc):
self.doc = doc
@@ -94,11 +94,6 @@ class WorkOrderStockReservation:
self.doc.db_set("status", self.doc.get_status())
def update_qty_in_stock_reservation(self, row, transferred_qty, row_wise_serial_batch):
# `transferred_qty` is the absolute qty transferred to WIP recomputed from submitted stock
# entries, so this method must also be able to *lower* it (e.g. when a transfer is cancelled).
# A fully-transferred entry is "Closed"; it must stay eligible here, otherwise cancelling the
# transfer can never reset its transferred_qty and the Store reservation is lost. Only truly
# cancelled entries (docstatus 2) are excluded.
names = frappe.get_all(
"Stock Reservation Entry",
filters={
@@ -106,10 +101,9 @@ class WorkOrderStockReservation:
"item_code": row.item_code,
"voucher_detail_no": row.name,
"warehouse": row.source_warehouse,
"docstatus": 1,
"status": ("not in", ["Closed", "Cancelled", "Completed"]),
},
pluck="name",
order_by="creation",
)
for name in names:
transferred_qty = self._apply_transferred_qty(name, transferred_qty, row_wise_serial_batch)
@@ -430,26 +424,14 @@ class WorkOrderStockReservation:
)
def cancel_reserved_qty_for_wip_and_fg(self, ste_doc):
# Reservations created by this stock entry are identified by `from_voucher_no`. They can be
# held against the Work Order *or* against another voucher -- e.g. the finished good of an
# SO-linked Work Order is reserved against the Sales Order. They must be cancelled directly:
# routing through the Work-Order-scoped `cancel_stock_reservation_entries` would silently skip
# any entry whose `voucher_type`/`voucher_no` is not the Work Order, leaving the finished good
# reserved and making the manufacture impossible to cancel (NegativeStockError).
cancelled = False
for row in ste_doc.items:
sre_list = frappe.get_all(
"Stock Reservation Entry",
filters={"from_voucher_no": ste_doc.name, "from_voucher_detail_no": row.name, "docstatus": 1},
pluck="name",
)
for name in sre_list:
frappe.get_doc("Stock Reservation Entry", name).cancel()
cancelled = True
if cancelled:
self.doc.reload()
self.doc.db_set("status", self.doc.get_status())
if sre_list:
unreserve_stock_for_work_order(self.doc, sre_list)
def release_reserved_qty_for_subcontract_transfer(self):
"""Free this Work Order's own reservation for items sent to a subcontractor.

View File

@@ -3417,137 +3417,6 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
def test_stock_reservation_moves_from_store_to_wip_on_transfer(self):
# Spec #7: a Material Transfer for Manufacture (Store -> WIP) for a reserve_stock Work Order
# must move the reservation from the Store warehouse to the WIP warehouse; cancelling the
# transfer must move it back.
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
make_stock_entry as make_stock_entry_test_record,
)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
try:
store = "Stores - _TC"
wip = "Work In Progress - _TC"
fg = make_item("Test SR Move FG", {"is_stock_item": 1}).name
rm = make_item("Test SR Move RM", {"is_stock_item": 1}).name
bom = make_bom(item=fg, raw_materials=[rm], source_warehouse=store, do_not_submit=True)
bom.save()
bom.submit()
make_stock_entry_test_record(item_code=rm, target=store, qty=10, basic_rate=100)
wo = make_wo_order_test_record(
production_item=fg,
qty=10,
bom_no=bom.name,
reserve_stock=1,
source_warehouse=store,
wip_warehouse=wip,
fg_warehouse=wip,
do_not_save=True,
)
wo.save()
wo.submit()
def reserved_in(warehouse):
return sum(
flt(r.reserved_qty) - flt(r.transferred_qty) - flt(r.delivered_qty) - flt(r.consumed_qty)
for r in frappe.get_all(
"Stock Reservation Entry",
filters={
"voucher_no": wo.name,
"item_code": rm,
"warehouse": warehouse,
"docstatus": 1,
},
fields=["reserved_qty", "transferred_qty", "delivered_qty", "consumed_qty"],
)
)
self.assertEqual(reserved_in(store), 10, "RM should be reserved in Store after WO submit")
self.assertEqual(reserved_in(wip), 0)
# Transfer Store -> WIP.
se = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
se.submit()
self.assertEqual(reserved_in(store), 0, "Store reservation should be freed after transfer")
self.assertEqual(reserved_in(wip), 10, "Reservation should move to WIP after transfer")
# Cancel the transfer -> reservation returns to Store.
se.cancel()
self.assertEqual(reserved_in(store), 10, "Cancelling transfer must restore Store reservation")
self.assertEqual(reserved_in(wip), 0)
finally:
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_sales_order_linked_work_order_reserves_finished_good(self):
# Spec #8: when a Work Order is linked to a Sales Order, manufacturing the finished good must
# reserve it against that Sales Order; cancelling the manufacture must release the reservation.
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
make_stock_entry as make_stock_entry_test_record,
)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
try:
warehouse = "_Test Warehouse - _TC"
wip = "_Test Warehouse 1 - _TC"
fg = make_item("Test SR SO-WO FG", {"is_stock_item": 1}).name
rm = make_item("Test SR SO-WO RM", {"is_stock_item": 1}).name
bom = make_bom(item=fg, raw_materials=[rm], source_warehouse=warehouse, do_not_submit=True)
bom.save()
bom.submit()
make_stock_entry_test_record(item_code=rm, target=warehouse, qty=10, basic_rate=100)
# The finished good is reserved in the Sales Order item's warehouse, so the WO must produce
# the FG into that same warehouse.
so = make_sales_order(item_code=fg, warehouse=warehouse, qty=10, rate=500)
wo = make_wo_order_test_record(
production_item=fg,
qty=10,
bom_no=bom.name,
sales_order=so.name,
reserve_stock=1,
source_warehouse=warehouse,
wip_warehouse=wip,
fg_warehouse=warehouse,
do_not_save=True,
)
wo.save()
wo.submit()
# Transfer the raw material to WIP, then manufacture the finished good.
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
transfer.submit()
manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
manufacture.submit()
def so_fg_reserved():
return sum(
flt(r.reserved_qty)
for r in frappe.get_all(
"Stock Reservation Entry",
filters={
"voucher_type": "Sales Order",
"voucher_no": so.name,
"item_code": fg,
"docstatus": 1,
},
fields=["reserved_qty"],
)
)
self.assertEqual(so_fg_reserved(), 10, "Finished good should be reserved against the Sales Order")
# Cancelling the manufacture releases the finished-good reservation.
manufacture.cancel()
self.assertEqual(so_fg_reserved(), 0, "Cancelling manufacture must release the SO reservation")
finally:
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_send_to_subcontractor_can_consume_work_order_reserved_stock(self):
from erpnext.buying.doctype.purchase_order.mapper import make_subcontracting_order
from erpnext.controllers.subcontracting_controller import make_rm_stock_entry

View File

@@ -46,8 +46,11 @@ from erpnext.manufacturing.doctype.work_order.services.operations import (
from erpnext.manufacturing.doctype.work_order.services.required_items import (
RequiredItemsService,
)
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
from erpnext.manufacturing.doctype.work_order.services.status import (
StatusService,
)
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
cancel_stock_reservation_entries,
get_consumed_qty,
get_reserved_qty_for_production,
@@ -55,9 +58,6 @@ from erpnext.manufacturing.doctype.work_order.services.reservation import (
get_sre_details,
make_stock_reservation_entries,
)
from erpnext.manufacturing.doctype.work_order.services.status import (
StatusService,
)
from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.item.item import validate_end_of_life
from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos
@@ -297,8 +297,8 @@ class WorkOrder(Document):
self.status = self.get_status()
self.validate_workstation_type()
self.reset_use_multi_level_bom()
WorkOrderStockReservation(self).set_reserve_stock()
WorkOrderStockReservation(self).validate_fg_warehouse_for_reservation()
StockReservationService(self).set_reserve_stock()
StockReservationService(self).validate_fg_warehouse_for_reservation()
self.validate_dates()
if self.source_warehouse:
@@ -311,7 +311,7 @@ class WorkOrder(Document):
):
self.set_required_items(reset_only_qty=len(self.get("required_items")))
WorkOrderStockReservation(self).enable_auto_reserve_stock()
StockReservationService(self).enable_auto_reserve_stock()
self.validate_operations_sequence()
self.validate_subcontracting_inward_order()
@@ -625,7 +625,7 @@ class WorkOrder(Document):
self.create_job_card_from_wo()
if self.reserve_stock:
WorkOrderStockReservation(self).update_stock_reservation()
StockReservationService(self).update_stock_reservation()
self.update_subcontracting_inward_order_received_items()
@@ -649,7 +649,7 @@ class WorkOrder(Document):
self.update_reserved_qty_for_production()
if self.reserve_stock:
WorkOrderStockReservation(self).update_stock_reservation()
StockReservationService(self).update_stock_reservation()
self.update_subcontracting_inward_order_received_items()

View File

@@ -84,7 +84,7 @@ class Workstation(Document):
def before_save(self):
if self.has_value_changed("workstation_type"):
self._set_data_based_on_workstation_type()
self.set_data_based_on_workstation_type()
self.set_hour_rate()
self.set_total_working_hours()
@@ -115,10 +115,6 @@ class Workstation(Document):
@frappe.whitelist()
def set_data_based_on_workstation_type(self):
self.check_permission("write")
self._set_data_based_on_workstation_type()
def _set_data_based_on_workstation_type(self):
if self.workstation_type:
data = frappe.get_all(
"Workstation Cost",
@@ -216,25 +212,21 @@ class Workstation(Document):
@frappe.whitelist()
def start_job(self, job_card: str, from_time: DateTimeLikeObject, employee: str):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("write")
doc.append("time_logs", {"from_time": from_time, "employee": employee})
doc.save()
doc.save(ignore_permissions=True)
return doc
@frappe.whitelist()
def complete_job(self, job_card: str, qty: float, to_time: DateTimeLikeObject):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("submit")
for row in doc.time_logs:
if not row.to_time:
row.to_time = to_time
row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) / 60
row.completed_qty = qty
doc.save()
doc.save(ignore_permissions=True)
doc.submit()
return doc
@@ -326,8 +318,6 @@ def get_status_color(status):
@frappe.whitelist()
def get_raw_materials(job_card: str):
frappe.has_permission("Job Card", "read", doc=job_card, throw=True)
raw_materials = frappe.get_all(
"Job Card",
fields=[
@@ -471,8 +461,6 @@ def check_workstation_for_holiday(workstation, from_datetime, to_datetime):
@frappe.whitelist()
def get_workstations(**kwargs):
frappe.has_permission("Workstation", "read", throw=True)
kwargs = frappe._dict(kwargs)
_workstation = frappe.qb.DocType("Workstation")
@@ -548,8 +536,13 @@ def update_job_card(job_card: str, method: str, **kwargs):
title=_("Not Allowed"),
)
frappe.has_permission("Job Card", "read", throw=True)
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("write")
# These methods mutate the Job Card, but frappe.get_doc does not enforce permissions —
# require write access before running anything.
frappe.has_permission("Job Card", "write", doc=doc, throw=True)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
@@ -565,8 +558,6 @@ def update_job_card(job_card: str, method: str, **kwargs):
@frappe.whitelist()
def validate_job_card(job_card: str, status: str):
frappe.has_permission("Job Card", "read", doc=job_card, throw=True)
job_card_details = frappe.db.get_value("Job Card", job_card, ["status", "for_quantity"], as_dict=1)
current_status = job_card_details.status

View File

@@ -96,8 +96,8 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
return `
<tr>
<td>${frappe.meta.get_label(doctype, fieldname)}</td>
<td>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
<td>${value1}</td>
<td>${value2}</td>
</tr>
`;
})
@@ -138,17 +138,13 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
.map((change, i) => {
let [fieldname, value1, value2] = change;
let th =
i === 0
? `<th rowspan="${values_changed.length}">${frappe.utils.escape_html(
cstr(item_code)
)}</th>`
: "";
i === 0 ? `<th rowspan="${values_changed.length}">${item_code}</th>` : "";
return `
<tr>
${th}
<td>${frappe.meta.get_label(child_doctype, fieldname)}</td>
<td>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
<td>${value1}</td>
<td>${value2}</td>
</tr>
`;
})
@@ -181,9 +177,7 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
let html = rows
.map((row) => {
let [, doc] = row;
let cells = fields
.map((df) => `<td>${frappe.utils.escape_html(cstr(doc[df.fieldname]))}</td>`)
.join("");
let cells = fields.map((df) => `<td>${doc[df.fieldname]}</td>`).join("");
return `<tr>${cells}</tr>`;
})
.join("");

View File

@@ -240,9 +240,9 @@ class BOMConfigurator {
}
frappe.call({
method: "add_item",
doc: this.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_item",
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
item_code: data.item_code,
fg_reference_id: node.data.name || this.frm.doc.name,
@@ -295,9 +295,9 @@ class BOMConfigurator {
}
frappe.call({
method: "add_sub_assembly",
doc: this.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
fg_reference_id: node.data.name || this.frm.doc.name,
bom_item: bom_item,
@@ -442,9 +442,9 @@ class BOMConfigurator {
}
frappe.call({
method: "add_sub_assembly",
doc: this.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
bom_item: bom_item,
fg_reference_id: node.data.name || this.frm.doc.name,
@@ -479,9 +479,9 @@ class BOMConfigurator {
delete_node(node, view) {
frappe.confirm(__("Are you sure you want to delete this Item?"), () => {
frappe.call({
method: "delete_node",
doc: this.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.delete_node",
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
doctype: node.data.doctype,
docname: node.data.name,
@@ -501,14 +501,16 @@ class BOMConfigurator {
this.frm.edit_bom_dialog = frappe.prompt(
fields,
(data) => {
let doctype = node.data.doctype || this.frm.doc.doctype;
let docname = node.data.name || this.frm.doc.name;
frappe.call({
method: "edit_bom_creator",
doc: me.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.edit_bom_creator",
args: {
doctype: doctype,
docname: docname,
data: data,
parent: node.data.parent_id || this.frm.doc.name,
},
callback: (r) => {
for (let key in data) {
@@ -538,13 +540,6 @@ class BOMConfigurator {
}
load_tree(response, node) {
// delete_node returns an empty response when nothing was removed; just
// refresh the node and bail out so we don't read undefined fields below.
if (!response?.message?.items) {
frappe.views.trees["BOM Configurator"].tree.load_children(node);
return;
}
let item_row = "";
let parent_dom = "";
let total_amount = response.message.raw_material_cost;

View File

@@ -607,9 +607,6 @@ erpnext.buying.get_items_from_product_bundle = function (frm) {
fieldname: "product_bundle",
options: "Product Bundle",
reqd: 1,
get_query: () => {
return { filters: { docstatus: 1, disabled: 0 } };
},
},
{
fieldtype: "Currency",
@@ -628,7 +625,7 @@ erpnext.buying.get_items_from_product_bundle = function (frm) {
method: "erpnext.stock.doctype.packed_item.packed_item.get_items_from_product_bundle",
args: {
row: {
product_bundle: args.product_bundle,
item_code: args.product_bundle,
quantity: args.quantity,
parenttype: frm.doc.doctype,
parent: frm.doc.name,

View File

@@ -201,7 +201,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
if (this.frm.fields_dict["items"].grid.get_field("product_bundle")) {
// restrict the version picker to enabled, submitted Product Bundles of the row's item
// restrict the version picker to submitted Product Bundles of the row's item
this.frm.set_query("product_bundle", "items", function (doc, cdt, cdn) {
let row = locals[cdt][cdn];
@@ -209,7 +209,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
filters: {
new_item_code: row.item_code,
docstatus: 1,
disabled: 0,
},
};
});

View File

@@ -76,14 +76,13 @@
"no_copy": 1
},
{
"allow_on_submit": 1,
"default": "0",
"description": "A disabled Product Bundle cannot be selected in transactions.",
"depends_on": "disabled",
"description": "Deprecated: use Cancel / Is Active instead. Retained for backward compatibility.",
"fieldname": "disabled",
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "Disabled",
"no_copy": 1
"read_only": 1
},
{
"fieldname": "amended_from",
@@ -103,7 +102,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-06-10 12:00:00.000000",
"modified": "2026-06-08 00:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Product Bundle",

View File

@@ -62,10 +62,8 @@ class ProductBundle(Document):
self.db_set("is_active", 0)
def on_update_after_submit(self):
# `is_active` and `disabled` are the only fields editable after submit; keep a
# single active version per parent item in sync when the user (re)activates a
# version. `disabled` is orthogonal: it parks a version without ceding the
# active slot, so re-enabling restores it without re-activation.
# `is_active` is the only field editable after submit; keep a single active
# version per parent item in sync when the user (re)activates a version.
if self.is_active:
self.make_active()
@@ -173,19 +171,17 @@ def get_next_version_index(existing_names: list[str]) -> int:
def get_active_product_bundle(item_code: str) -> str | None:
"""Return the name of the active, enabled, submitted Product Bundle for
``item_code``, else None.
"""Return the name of the active, submitted Product Bundle for ``item_code``, else None.
This is the single resolution entry point for every consumer of bundles; it
replaces the legacy ``exists("Product Bundle", {name/new_item_code, disabled: 0})``
lookups that assumed one mutable bundle per item. A disabled bundle resolves to
None even if it still holds the active slot for its parent item.
lookups that assumed one mutable bundle per item.
"""
if not item_code:
return None
return frappe.db.get_value(
"Product Bundle",
{"new_item_code": item_code, "is_active": 1, "docstatus": 1, "disabled": 0},
{"new_item_code": item_code, "is_active": 1, "docstatus": 1},
"name",
)

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.listview_settings["Product Bundle"] = {
add_fields: ["is_active", "disabled"],
get_indicator(doc) {
// Draft and Cancelled fall through to the standard docstatus indicators;
// this only refines submitted bundles.
if (doc.disabled) {
return [__("Disabled"), "grey", "disabled,=,1"];
}
if (doc.docstatus === 1 && doc.is_active) {
return [__("Active"), "green", "is_active,=,1|disabled,=,0|docstatus,=,1"];
}
// inactive submitted versions keep the default "Submitted" indicator
},
};

View File

@@ -103,38 +103,6 @@ class TestProductBundle(ERPNextTestSuite):
bundle.items[0].qty = 99
self.assertRaises(frappe.exceptions.UpdateAfterSubmitError, bundle.save)
def test_disabled_bundle_is_not_resolved(self):
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
bundle.disabled = 1
bundle.save()
self.assertIsNone(get_active_product_bundle(self.parent))
# disabling parks the version without ceding the active slot, so re-enabling
# restores resolution without re-activation
self.assertEqual(frappe.db.get_value("Product Bundle", bundle.name, "is_active"), 1)
bundle.disabled = 0
bundle.save()
self.assertEqual(get_active_product_bundle(self.parent), bundle.name)
def test_item_where_used_report_shows_disabled_flag(self):
from erpnext.stock.report.item_where_used.item_where_used import execute
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
bundle.disabled = 1
bundle.save()
_, component_rows = execute({"item": "_Test PB Child A", "section": "Where Used"})
rows = [r for r in component_rows if r.document_name == bundle.name]
self.assertTrue(rows)
self.assertEqual(rows[0].disabled, 1)
self.assertEqual(rows[0].is_active, 1)
_, parent_rows = execute({"item": self.parent, "section": "References"})
rows = [r for r in parent_rows if r.document_name == bundle.name]
self.assertTrue(rows)
self.assertEqual(rows[0].disabled, 1)
def test_child_cannot_be_active_bundle(self):
make_product_bundle(self.parent, ["_Test PB Child A"])
outer = make_item("_Test PB Outer", {"is_stock_item": 0, "is_sales_item": 1}).name

View File

@@ -376,27 +376,12 @@ def make_delivery_note(
dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1))
dn_item.warehouse = sre.warehouse
if sre.reservation_based_on == "Serial and Batch" and (sre.has_serial_no or sre.has_batch_no):
if use_serial_batch_fields:
# Carry the reserved serial/batch in the row fields. A single field can't hold
# multiple batches, so fall back to a bundle in that case.
dn_item.use_serial_batch_fields = 1
sb_entries = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": sre.name},
fields=["serial_no", "batch_no"],
)
serial_nos = [d.serial_no for d in sb_entries if d.serial_no]
batch_nos = list({d.batch_no for d in sb_entries if d.batch_no})
if serial_nos:
dn_item.serial_no = "\n".join(serial_nos)
if len(batch_nos) == 1:
dn_item.batch_no = batch_nos[0]
elif len(batch_nos) > 1:
dn_item.use_serial_batch_fields = 0
dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name
else:
dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name
if (
not use_serial_batch_fields
and sre.reservation_based_on == "Serial and Batch"
and (sre.has_serial_no or sre.has_batch_no)
):
dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name
target_doc.append("items", dn_item)
# Correct rows index.

View File

@@ -23,8 +23,8 @@ from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
)
from erpnext.selling.doctype.customer.customer import check_credit_limit
from erpnext.selling.doctype.sales_order.services.delivery_schedule import DeliveryScheduleService
from erpnext.selling.doctype.sales_order.services.reservation import SalesOrderStockReservation
from erpnext.selling.doctype.sales_order.services.status import StatusService
from erpnext.selling.doctype.sales_order.services.stock_reservation import StockReservationService
from erpnext.selling.doctype.sales_order.services.subcontracting import SubcontractingService
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import has_reserved_stock
@@ -226,7 +226,7 @@ class SalesOrder(SellingController):
self.validate_for_items()
self.validate_warehouse()
self.validate_drop_ship()
SalesOrderStockReservation(self).validate_reserved_stock()
StockReservationService(self).validate_reserved_stock()
self.validate_serial_no_based_delivery()
validate_against_blanket_order(self)
validate_inter_company_party(
@@ -248,7 +248,7 @@ class SalesOrder(SellingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse")
if not self.get("is_subcontracted"):
SalesOrderStockReservation(self).enable_auto_reserve_stock()
StockReservationService(self).enable_auto_reserve_stock()
def set_has_unit_price_items(self):
"""
@@ -542,7 +542,7 @@ class SalesOrder(SellingController):
StatusService(self).update_status(status)
def update_reserved_qty(self, so_item_rows=None):
SalesOrderStockReservation(self).update_reserved_qty(so_item_rows)
StockReservationService(self).update_reserved_qty(so_item_rows)
def on_update_after_submit(self):
self.calculate_commission()
@@ -652,7 +652,7 @@ class SalesOrder(SellingController):
@frappe.whitelist()
def has_unreserved_stock(self, table_name: str = "items") -> dict:
"""Returns unreserved qty per item if there is any unreserved item in the Sales Order."""
return SalesOrderStockReservation(self).has_unreserved_stock(table_name)
return StockReservationService(self).has_unreserved_stock(table_name)
@frappe.whitelist()
def create_stock_reservation_entries(
@@ -662,14 +662,14 @@ class SalesOrder(SellingController):
notify: bool = True,
) -> None:
"""Creates Stock Reservation Entries for Sales Order Items."""
SalesOrderStockReservation(self).create_stock_reservation_entries(
StockReservationService(self).create_stock_reservation_entries(
items_details, from_voucher_type, notify
)
@frappe.whitelist()
def cancel_stock_reservation_entries(self, sre_list: list | None = None, notify: bool = True) -> None:
"""Cancel Stock Reservation Entries for Sales Order Items."""
SalesOrderStockReservation(self).cancel_stock_reservation_entries(sre_list, notify)
StockReservationService(self).cancel_stock_reservation_entries(sre_list, notify)
def set_missing_values(self, for_validate=False):
super().set_missing_values(for_validate)

View File

@@ -15,7 +15,7 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor
from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty
class SalesOrderStockReservation:
class StockReservationService:
def __init__(self, doc):
self.doc = doc

View File

@@ -13,8 +13,6 @@ def execute(filters=None):
if not filters:
filters = {}
validate_filters(filters)
columns = get_columns(filters)
entries = get_entries(filters)
item_details = get_item_details()
@@ -51,17 +49,10 @@ def execute(filters=None):
return columns, data
def validate_filters(filters):
ALLOWED_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note"]
def get_columns(filters):
if not filters.get("doc_type"):
msgprint(_("Please select the document type first"), raise_exception=1)
if filters.get("doc_type") not in ALLOWED_DOCTYPES:
frappe.throw(_("{0}, {1} or {2} are the only allowed options.").format(*ALLOWED_DOCTYPES))
def get_columns(filters):
columns = [
{
"label": _(filters["doc_type"]),

View File

@@ -150,11 +150,15 @@ class Employee(NestedSet):
)
def validate_user_details(self):
if not self.user_id:
return
if self.user_id:
data = frappe.db.get_value("User", self.user_id, ["enabled"], as_dict=1)
self.validate_for_enabled_user_id()
self.validate_duplicate_user_id()
if not data:
self.user_id = None
return
self.validate_for_enabled_user_id(data.get("enabled", 0))
self.validate_duplicate_user_id()
def validate_auto_user_creation(self):
if self.create_user_automatically and not (
@@ -175,7 +179,6 @@ class Employee(NestedSet):
if self.user_id:
self.update_user()
self.update_user_permissions()
self.update_user_status()
self.reset_employee_emails_cache()
def before_insert(self):
@@ -293,23 +296,12 @@ class Employee(NestedSet):
if not self.relieving_date:
throw(_("Please enter relieving date."))
def validate_for_enabled_user_id(self):
if not frappe.db.exists("User", self.user_id):
def validate_for_enabled_user_id(self, enabled):
if enabled is None:
frappe.throw(_("User {0} does not exist").format(self.user_id))
def update_user_status(self):
if not self.user_id:
return
if not self.has_value_changed("status") and not self.has_value_changed("user_id"):
return
user = frappe.get_doc("User", self.user_id)
enabled = user.enabled
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
user.enabled = not enabled
# Keep linked User status in sync from the Employee lifecycle and record the audit log.
user.save(ignore_permissions=True)
frappe.db.set_value("User", self.user_id, "enabled", not enabled)
def validate_duplicate_user_id(self):
Employee = frappe.qb.DocType("Employee")

View File

@@ -642,8 +642,6 @@ class TransactionDeletionRecord(Document):
@frappe.whitelist()
def start_deletion_tasks(self):
self.check_permission("write")
# This method is the entry point for the chain of events that follow
self.db_set("status", "Running")
self._set_deletion_cache()

View File

@@ -368,8 +368,6 @@ def get_default_address(out, name):
@frappe.whitelist()
def get_contact_display(contact: str):
frappe.has_permission("Contact", "read", doc=contact, throw=True)
contact_info = frappe.db.get_value(
"Contact", contact, ["first_name", "last_name", "phone", "mobile_no"], as_dict=1
)
@@ -472,8 +470,6 @@ def get_attachments(delivery_stop):
@frappe.whitelist()
def get_driver_email(driver: str):
frappe.has_permission("Driver", "read", doc=driver, throw=True)
employee = frappe.db.get_value("Driver", driver, "employee")
email = frappe.db.get_value("Employee", employee, "prefered_email")
return {"email": email}

View File

@@ -8,7 +8,6 @@ import json
import frappe
import frappe.defaults
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt
@@ -175,6 +174,30 @@ def reset_packing_list(doc):
return reset_table
def get_product_bundle_items(item_code):
product_bundle = frappe.qb.DocType("Product Bundle")
product_bundle_item = frappe.qb.DocType("Product Bundle Item")
query = (
frappe.qb.from_(product_bundle_item)
.join(product_bundle)
.on(product_bundle_item.parent == product_bundle.name)
.select(
product_bundle_item.item_code,
product_bundle_item.qty,
product_bundle_item.uom,
product_bundle_item.description,
)
.where(
(product_bundle.new_item_code == item_code)
& (product_bundle.is_active == 1)
& (product_bundle.docstatus == 1)
)
.orderby(product_bundle_item.idx)
)
return query.run(as_dict=True)
def get_product_bundle_items_by_name(bundle_name):
"Component rows of a specific Product Bundle version."
product_bundle_item = frappe.qb.DocType("Product Bundle Item")
@@ -196,25 +219,14 @@ def get_bundle_version_for_row(item_row):
Honours a version explicitly chosen on the row (validated to be a submitted
bundle of that item); otherwise falls back to the item's active version. A stale
choice (e.g. left over after changing the item) self-heals back to the active
one, but a disabled choice blocks the transaction instead of silently switching
versions behind the user's back.
choice (e.g. left over after changing the item) self-heals back to the active one.
"""
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
chosen = item_row.get("product_bundle") if item_row.meta.has_field("product_bundle") else None
if chosen:
bundle = frappe.db.get_value(
"Product Bundle", chosen, ["new_item_code", "docstatus", "disabled"], as_dict=True
)
bundle = frappe.db.get_value("Product Bundle", chosen, ["new_item_code", "docstatus"], as_dict=True)
if bundle and bundle.new_item_code == item_row.item_code and bundle.docstatus == 1:
if bundle.disabled:
frappe.throw(
_("Row #{0}: Product Bundle {1} is disabled and cannot be used in transactions.").format(
item_row.idx, frappe.bold(chosen)
),
title=_("Disabled Product Bundle"),
)
return chosen
return get_active_product_bundle(item_row.item_code)
@@ -433,31 +445,9 @@ def on_doctype_update():
@frappe.whitelist()
def get_items_from_product_bundle(row: str):
"""Item details for each component of a Product Bundle.
``row.product_bundle`` selects a specific version by document name (the buying
dialog passes this); ``row.item_code`` is the legacy contract, resolving the
parent item's active version.
"""
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
row, items = ItemDetailsCtx(json.loads(row)), []
if bundle_name := row.get("product_bundle"):
frappe.has_permission("Product Bundle", "read", bundle_name, throw=True)
bundle = frappe.db.get_value("Product Bundle", bundle_name, ["docstatus", "disabled"], as_dict=True)
if not bundle or bundle.docstatus != 1:
frappe.throw(_("Product Bundle {0} is not submitted").format(frappe.bold(bundle_name)))
if bundle.disabled:
frappe.throw(
_("Product Bundle {0} is disabled and cannot be used in transactions.").format(
frappe.bold(bundle_name)
)
)
elif bundle_name := get_active_product_bundle(row.get("item_code")):
frappe.has_permission("Product Bundle", "read", bundle_name, throw=True)
bundled_items = get_product_bundle_items_by_name(bundle_name) if bundle_name else []
bundled_items = get_product_bundle_items(row["item_code"])
for item in bundled_items:
row.update(
{

View File

@@ -165,80 +165,6 @@ class TestPackedItem(ERPNextTestSuite):
self.assertEqual(so.items[0].product_bundle, v1)
self.assertEqual(sorted(pi.item_code for pi in so.packed_items), sorted(self.bundle_items))
def test_disabled_bundle_blocks_transaction(self):
"A row that explicitly references a disabled version cannot be saved."
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
version = get_active_product_bundle(self.bundle)
so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse, do_not_submit=True)
self.assertEqual(so.items[0].product_bundle, version)
frappe.db.set_value("Product Bundle", version, "disabled", 1)
self.assertRaises(frappe.ValidationError, so.save)
def test_disabled_bundle_is_not_packed(self):
"Without an explicit version, a disabled bundle is not treated as a bundle at all."
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
version = get_active_product_bundle(self.bundle2)
frappe.db.set_value("Product Bundle", version, "disabled", 1)
so = make_sales_order(item_code=self.bundle2, qty=1, warehouse=self.warehouse, do_not_submit=True)
self.assertEqual(so.items[0].is_product_bundle, 0)
self.assertFalse(so.items[0].product_bundle)
self.assertFalse(so.get("packed_items"))
def test_get_items_from_product_bundle_endpoint(self):
"The buying dialog passes the chosen version by document name (legacy: parent item code)."
import json
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
from erpnext.stock.doctype.packed_item.packed_item import get_items_from_product_bundle
ctx = {
"quantity": 2,
"doctype": "Purchase Order",
"parenttype": "Purchase Order",
"company": "_Test Company",
"currency": "INR",
"conversion_rate": 1,
"transaction_date": nowdate(),
}
# by document name, as the buying dialog sends it (bundle names are PB-prefixed
# since versioning, so they no longer double as the parent item code)
version = get_active_product_bundle(self.bundle)
items = get_items_from_product_bundle(json.dumps({"product_bundle": version, **ctx}))
self.assertEqual(sorted(i.item_code for i in items), sorted(self.bundle_items))
self.assertEqual([i.qty for i in items], [4, 4])
# legacy contract: the parent item code resolves to its active version
items = get_items_from_product_bundle(json.dumps({"item_code": self.bundle, **ctx}))
self.assertEqual(sorted(i.item_code for i in items), sorted(self.bundle_items))
# an unsubmitted version is rejected
draft = frappe.get_doc(
{
"doctype": "Product Bundle",
"new_item_code": make_item(properties={"is_stock_item": 0}).name,
"items": [{"item_code": self.bundle_items[0], "qty": 1}],
}
).insert()
self.assertRaises(
frappe.ValidationError,
get_items_from_product_bundle,
json.dumps({"product_bundle": draft.name, **ctx}),
)
# a disabled version is rejected
frappe.db.set_value("Product Bundle", version, "disabled", 1)
self.addCleanup(frappe.db.set_value, "Product Bundle", version, "disabled", 0)
self.assertRaises(
frappe.ValidationError,
get_items_from_product_bundle,
json.dumps({"product_bundle": version, **ctx}),
)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_recurring_bundle_item(self):
"Test impact on packed items if same bundle item is added and removed."

View File

@@ -15,9 +15,7 @@ from erpnext.stock.doctype.purchase_receipt.services.billing_status import Billi
from erpnext.stock.doctype.purchase_receipt.services.provisional_accounting import (
ProvisionalAccountingService,
)
from erpnext.stock.doctype.purchase_receipt.services.reservation import (
PurchaseReceiptStockReservation,
)
from erpnext.stock.doctype.purchase_receipt.services.stock_reservation import StockReservationService
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -376,7 +374,7 @@ class PurchaseReceipt(BuyingController):
self.make_gl_entries()
self.repost_future_sle_and_gle()
self.set_consumed_qty_in_subcontract_order()
PurchaseReceiptStockReservation(self).reserve_stock()
StockReservationService(self).reserve_stock()
self.update_received_qty_if_from_pp()
def update_received_qty_if_from_pp(self):

View File

@@ -10,7 +10,7 @@ from frappe.utils import get_datetime
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
class PurchaseReceiptStockReservation:
class StockReservationService:
def __init__(self, doc):
self.doc = doc

View File

@@ -127,8 +127,6 @@ def get_contact_name(ref_doctype: str, docname: str):
@frappe.whitelist()
def get_company_contact(user: str):
frappe.has_permission("User", "read", throw=True)
contact = frappe.db.get_value(
"User",
user,

View File

@@ -9,8 +9,6 @@ frappe.ui.form.on("Stock Entry", {
setup: function (frm) {
frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"];
frm.trigger("toggle_enable_for_stock_uom_qty");
frm.set_indicator_formatter("item_code", function (doc) {
if (!doc.s_warehouse) {
return "blue";
@@ -278,20 +276,6 @@ frappe.ui.form.on("Stock Entry", {
});
},
toggle_enable_for_stock_uom_qty: function (frm) {
frappe.call({
method: "erpnext.stock.doctype.stock_settings.stock_settings.get_enable_stock_uom_editing",
callback: (r) => {
if (r.message) {
frm.fields_dict["items"].grid.toggle_enable(
"transfer_qty",
r.message.allow_to_edit_stock_uom_qty_for_stock_entry
);
}
},
});
},
refresh: function (frm) {
frm.trigger("get_items_from_transit_entry");
frm.trigger("toggle_warehouse_fields");
@@ -1032,21 +1016,6 @@ frappe.ui.form.on("Stock Entry Detail", {
frm.events.set_basic_rate(frm, cdt, cdn);
},
transfer_qty(frm, cdt, cdn) {
let item = locals[cdt][cdn];
let old_conversion_factor = item.conversion_factor;
let conversion_factor = 1.0;
if (flt(item.qty) && flt(item.transfer_qty)) {
conversion_factor = flt(item.transfer_qty) / flt(item.qty);
}
if (old_conversion_factor !== conversion_factor) {
item.conversion_factor = conversion_factor;
refresh_field("conversion_factor", item.name, item.parentfield);
frm.events.set_basic_rate(frm, cdt, cdn);
}
},
s_warehouse(frm, cdt, cdn) {
frm.events.get_warehouse_details(frm, cdt, cdn);

View File

@@ -1061,8 +1061,8 @@ class StockEntry(StockController, SubcontractingInwardController):
return getattr(self, "_wo_doc", None)
def make_stock_reserve_for_wip_and_fg(self):
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
)
if self.is_stock_reserve_for_work_order():
@@ -1076,7 +1076,7 @@ class StockEntry(StockController, SubcontractingInwardController):
):
return
WorkOrderStockReservation(pro_doc).set_reserved_qty_for_wip_and_fg(self)
StockReservationService(pro_doc).set_reserved_qty_for_wip_and_fg(self)
def reserve_stock_for_subcontracting(self):
if self.purpose == "Send to Subcontractor" and frappe.get_value(
@@ -1103,8 +1103,8 @@ class StockEntry(StockController, SubcontractingInwardController):
)
def cancel_stock_reserve_for_wip_and_fg(self):
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
)
if self.is_stock_reserve_for_work_order():
@@ -1116,7 +1116,7 @@ class StockEntry(StockController, SubcontractingInwardController):
):
return
WorkOrderStockReservation(pro_doc).cancel_reserved_qty_for_wip_and_fg(self)
StockReservationService(pro_doc).cancel_reserved_qty_for_wip_and_fg(self)
def is_stock_reserve_for_work_order(self):
if (
@@ -1133,8 +1133,8 @@ class StockEntry(StockController, SubcontractingInwardController):
# purpose), so the owning Work Order is derived from the Subcontracting Order / Purchase Order
# that raised the transfer. Each such Work Order that reserves stock gets its reservation for
# the sent items released, so the negative-stock guard stops blocking the consumption.
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
)
if self.purpose != "Send to Subcontractor":
@@ -1142,7 +1142,7 @@ class StockEntry(StockController, SubcontractingInwardController):
for wo_name in self.get_reserved_work_orders_for_subcontracting():
pro_doc = frappe.get_doc("Work Order", wo_name)
WorkOrderStockReservation(pro_doc).release_reserved_qty_for_subcontract_transfer()
StockReservationService(pro_doc).release_reserved_qty_for_subcontract_transfer()
def get_reserved_work_orders_for_subcontracting(self):
job_cards = set()

View File

@@ -1587,7 +1587,7 @@ def create_stock_reservation_entries_for_so_items(
):
"""Creates Stock Reservation Entries for Sales Order Items."""
from erpnext.selling.doctype.sales_order.services.reservation import get_unreserved_qty
from erpnext.selling.doctype.sales_order.services.stock_reservation import get_unreserved_qty
if not from_voucher_type and (
sales_order.get("_action") == "submit"

View File

@@ -4,7 +4,7 @@
from random import randint
import frappe
from frappe.utils import flt, today
from frappe.utils import today
from erpnext.selling.doctype.sales_order.mapper import create_pick_list, make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
@@ -313,231 +313,6 @@ class TestStockReservationEntry(ERPNextTestSuite):
for sre_detail in sre_details:
self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty)
@ERPNextTestSuite.change_settings(
"Stock Settings", {"enable_stock_reservation": 1, "allow_partial_reservation": 1}
)
def test_reservation_restored_on_delivery_note_cancel(self) -> None:
# Cancellation path (spec #1): delivering reserved stock via a Delivery Note marks the SRE
# delivered; cancelling that Delivery Note must restore the reservation (delivered_qty -> 0),
# otherwise the reserved stock is silently lost.
so = make_sales_order(
item_code=self.sr_item.name,
warehouse=self.warehouse,
qty=10,
rate=100,
do_not_submit=True,
)
so.reserve_stock = 1
so.items[0].reserve_stock = 1
so.save()
so.submit()
so.create_stock_reservation_entries()
def sre():
return frappe.get_all(
"Stock Reservation Entry",
filters={"voucher_no": so.name, "item_code": self.sr_item.name, "docstatus": 1},
fields=["reserved_qty", "delivered_qty", "status"],
)[0]
self.assertEqual(sre().reserved_qty, 10)
self.assertEqual(sre().delivered_qty, 0)
dn = make_delivery_note(so.name)
dn.submit()
after = sre()
self.assertEqual(after.delivered_qty, 10, "Delivery Note should mark the reservation delivered")
self.assertEqual(after.status, "Delivered")
dn.cancel()
restored = sre()
self.assertEqual(
restored.delivered_qty, 0, "Cancelling the Delivery Note must restore the reservation"
)
self.assertEqual(restored.status, "Reserved")
@ERPNextTestSuite.change_settings(
"Stock Settings", {"enable_stock_reservation": 1, "allow_negative_stock": 0}
)
def test_reserved_stock_cannot_be_delivered_against_a_different_sales_order(self) -> None:
# Spec #2: stock reserved for one Sales Order must not be deliverable through a Delivery Note
# raised for a different Sales Order. Pin allow_negative_stock off so the guard is enforced
# regardless of any global Stock Settings left enabled by other tests in the suite.
from erpnext.stock.stock_ledger import NegativeStockError
item_doc = make_item(properties={"is_stock_item": 1, "valuation_rate": 100})
item = item_doc.name
warehouse = self.warehouse
create_material_receipt(items={item: item_doc}, warehouse=warehouse, qty=10)
# SO-A reserves the entire available stock.
so_a = make_sales_order(item_code=item, warehouse=warehouse, qty=10, rate=100, do_not_submit=True)
so_a.reserve_stock = 1
so_a.items[0].reserve_stock = 1
so_a.save()
so_a.submit()
so_a.create_stock_reservation_entries()
self.assertTrue(has_reserved_stock("Sales Order", so_a.name))
# SO-B (a different order) for the same item must not be able to deliver the reserved stock.
so_b = make_sales_order(item_code=item, warehouse=warehouse, qty=10, rate=100, do_not_submit=True)
so_b.save()
so_b.submit()
dn = make_delivery_note(so_b.name)
dn.save()
self.assertRaises(NegativeStockError, dn.submit)
@ERPNextTestSuite.change_settings("Stock Settings", {"enable_stock_reservation": 1})
def test_stock_can_be_unreserved_and_reserved_against_another_sales_order(self) -> None:
# Spec #3: a user can manually unreserve stock from one Sales Order and reserve the same stock
# against another Sales Order.
item_doc = make_item(properties={"is_stock_item": 1, "valuation_rate": 100})
item = item_doc.name
warehouse = self.warehouse
create_material_receipt(items={item: item_doc}, warehouse=warehouse, qty=10)
so_a = make_sales_order(item_code=item, warehouse=warehouse, qty=10, rate=100, do_not_submit=True)
so_a.reserve_stock = 1
so_a.items[0].reserve_stock = 1
so_a.save()
so_a.submit()
so_a.create_stock_reservation_entries()
self.assertTrue(has_reserved_stock("Sales Order", so_a.name))
so_b = make_sales_order(item_code=item, warehouse=warehouse, qty=10, rate=100, do_not_submit=True)
so_b.reserve_stock = 1
so_b.items[0].reserve_stock = 1
so_b.save()
so_b.submit()
# With all stock reserved by SO-A, SO-B cannot reserve anything yet.
so_b.create_stock_reservation_entries()
self.assertFalse(has_reserved_stock("Sales Order", so_b.name))
# Manually unreserve SO-A, then SO-B can reserve the freed stock.
cancel_stock_reservation_entries("Sales Order", so_a.name)
self.assertFalse(has_reserved_stock("Sales Order", so_a.name))
so_b.create_stock_reservation_entries()
self.assertTrue(has_reserved_stock("Sales Order", so_b.name))
@ERPNextTestSuite.change_settings(
"Stock Settings",
{
"enable_stock_reservation": 1,
"auto_reserve_serial_and_batch": 1,
"pick_serial_and_batch_based_on": "FIFO",
},
)
def test_serial_and_batch_reservation_can_be_unreserved(self) -> None:
# Spec #9 (cancellation): an auto-reserved serial/batch Sales Order reservation pins specific
# serial/batch entries on the SRE (`sb_entries`). Manually unreserving it must cancel the SRE
# (and its pinned entries) and free the stock for another order.
items_details = create_items()
create_material_receipt(items_details, self.warehouse, qty=10)
serial_item = next(
name for name, p in items_details.items() if p.get("has_serial_no") and not p.get("has_batch_no")
)
batch_item = next(
name for name, p in items_details.items() if p.get("has_batch_no") and not p.get("has_serial_no")
)
item_list = [
{"item_code": serial_item, "warehouse": self.warehouse, "qty": 10, "rate": 100},
{"item_code": batch_item, "warehouse": self.warehouse, "qty": 10, "rate": 100},
]
so = make_sales_order(item_list=item_list, warehouse=self.warehouse)
so.create_stock_reservation_entries()
so.load_from_db()
def sre_row(so_item):
return frappe.get_all(
"Stock Reservation Entry",
filters={"voucher_no": so.name, "voucher_detail_no": so_item, "docstatus": 1},
fields=["name", "reserved_qty", "reservation_based_on"],
)
# Each item is reserved on a Serial-and-Batch basis with the specific entries pinned.
self.assertTrue(has_reserved_stock("Sales Order", so.name))
for item in so.items:
rows = sre_row(item.name)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].reserved_qty, 10)
self.assertEqual(rows[0].reservation_based_on, "Serial and Batch")
pinned = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": rows[0].name, "parentfield": "sb_entries"},
)
self.assertGreaterEqual(len(pinned), 1, "serial/batch entries should be pinned on the SRE")
# Manually unreserve: the SRE (and its pinned entries) must be cancelled and the stock freed.
cancel_stock_reservation_entries("Sales Order", so.name)
self.assertFalse(has_reserved_stock("Sales Order", so.name))
for item in so.items:
self.assertEqual(sre_row(item.name), [])
# The freed serial/batch stock can be reserved by another Sales Order.
so_b = make_sales_order(item_list=item_list, warehouse=self.warehouse)
so_b.create_stock_reservation_entries()
self.assertTrue(has_reserved_stock("Sales Order", so_b.name))
@ERPNextTestSuite.change_settings(
"Stock Settings",
{
"enable_stock_reservation": 1,
"auto_reserve_serial_and_batch": 1,
"pick_serial_and_batch_based_on": "FIFO",
"use_serial_batch_fields": 1,
},
)
def test_serial_and_batch_reserved_stock_delivery_and_cancel(self) -> None:
# Regression: delivering a serial/batch reserved Sales Order used to crash with
# "Serial and Batch Bundle None not found" when the delivered serial/batch was carried in the
# row's serial_no/batch_no fields (use_serial_batch_fields) rather than a bundle. Delivery
# must mark the reservation delivered, and cancelling the Delivery Note must restore it.
items_details = create_items()
create_material_receipt(items_details, self.warehouse, qty=10)
serial_item = next(
name for name, p in items_details.items() if p.get("has_serial_no") and not p.get("has_batch_no")
)
batch_item = next(
name for name, p in items_details.items() if p.get("has_batch_no") and not p.get("has_serial_no")
)
item_list = [
{"item_code": serial_item, "warehouse": self.warehouse, "qty": 10, "rate": 100},
{"item_code": batch_item, "warehouse": self.warehouse, "qty": 10, "rate": 100},
]
so = make_sales_order(item_list=item_list, warehouse=self.warehouse)
so.create_stock_reservation_entries()
def sre_row(so_item):
return frappe.get_all(
"Stock Reservation Entry",
filters={"voucher_no": so.name, "voucher_detail_no": so_item, "docstatus": 1},
fields=["reserved_qty", "delivered_qty", "status"],
)[0]
# Deliver the reserved stock (serial/batch carried in row fields, no bundle).
dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True})
dn.save()
dn.submit()
for item in so.items:
row = sre_row(item.name)
self.assertEqual(
row.delivered_qty, 10, "delivery should mark the serial/batch reservation delivered"
)
self.assertEqual(row.status, "Delivered")
# Cancelling the Delivery Note restores the reservation.
dn.cancel()
for item in so.items:
row = sre_row(item.name)
self.assertEqual(row.delivered_qty, 0, "DN cancel must restore the serial/batch reservation")
self.assertEqual(row.status, "Reserved")
@ERPNextTestSuite.change_settings(
"Stock Settings",
{

View File

@@ -21,7 +21,6 @@
"stock_uom",
"allow_to_edit_stock_uom_qty_for_sales",
"allow_to_edit_stock_uom_qty_for_purchase",
"allow_to_edit_stock_uom_qty_for_stock_entry",
"allow_uom_with_conversion_rate_defined_in_item",
"warehouse_defaults_section",
"default_warehouse",
@@ -405,13 +404,6 @@
"fieldtype": "Check",
"label": "Allow to edit stock UOM qty for Purchase documents"
},
{
"default": "0",
"documentation_url": "https://docs.frappe.io/erpnext/stock-settings#why-to-edit-stock-qty-qty-as-per-stock-uom",
"fieldname": "allow_to_edit_stock_uom_qty_for_stock_entry",
"fieldtype": "Check",
"label": "Allow to edit stock UOM qty for Stock Entry"
},
{
"default": "0",
"depends_on": "eval: doc.enable_stock_reservation",
@@ -602,7 +594,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-13 12:38:02.202183",
"modified": "2026-06-03 12:38:02.202183",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -31,7 +31,6 @@ class StockSettings(Document):
allow_partial_reservation: DF.Check
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
allow_to_edit_stock_uom_qty_for_sales: DF.Check
allow_to_edit_stock_uom_qty_for_stock_entry: DF.Check
allow_to_make_quality_inspection_after_purchase_or_delivery: DF.Check
allow_uom_with_conversion_rate_defined_in_item: DF.Check
auto_create_serial_and_batch_bundle_for_outward: DF.Check
@@ -112,7 +111,6 @@ class StockSettings(Document):
self.validate_auto_insert_price_list_rate_if_missing()
self.change_precision_for_for_sales()
self.change_precision_for_purchase()
self.change_precision_for_stock_entry()
self.validate_do_not_use_batchwise_valuation()
def validate_do_not_use_batchwise_valuation(self):
@@ -291,18 +289,6 @@ class StockSettings(Document):
]
self.make_property_setter_for_precision(doctypes)
def change_precision_for_stock_entry(self):
doc_before_save = self.get_doc_before_save()
if doc_before_save and (
doc_before_save.allow_to_edit_stock_uom_qty_for_stock_entry
== self.allow_to_edit_stock_uom_qty_for_stock_entry
):
return
if self.allow_to_edit_stock_uom_qty_for_stock_entry:
doctypes = ["Stock Entry Detail"]
self.make_property_setter_for_precision(doctypes)
@staticmethod
def make_property_setter_for_precision(doctypes):
for doctype in doctypes:
@@ -335,10 +321,6 @@ def clean_all_descriptions():
def get_enable_stock_uom_editing():
return frappe.get_single_value(
"Stock Settings",
[
"allow_to_edit_stock_uom_qty_for_sales",
"allow_to_edit_stock_uom_qty_for_purchase",
"allow_to_edit_stock_uom_qty_for_stock_entry",
],
["allow_to_edit_stock_uom_qty_for_sales", "allow_to_edit_stock_uom_qty_for_purchase"],
as_dict=1,
)

View File

@@ -18,8 +18,7 @@
"label": "UOM",
"oldfieldname": "uom",
"oldfieldtype": "Link",
"options": "UOM",
"reqd": 1
"options": "UOM"
},
{
"fieldname": "conversion_factor",
@@ -38,7 +37,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-11 23:02:54.800673",
"modified": "2026-04-27 02:22:52.652036",
"modified_by": "Administrator",
"module": "Stock",
"name": "UOM Conversion Detail",

View File

@@ -18,7 +18,7 @@ class UOMConversionDetail(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
uom: DF.Link
uom: DF.Link | None
# end: auto-generated types
pass

View File

@@ -135,14 +135,11 @@ def get_linked_cancelled_sabb(filters):
@frappe.whitelist()
def fix_sabb_entries(selected_rows: str | list):
frappe.has_permission("Serial and Batch Bundle", "write", throw=True)
if isinstance(selected_rows, str):
selected_rows = frappe.parse_json(selected_rows)
for row in selected_rows:
doc = frappe.get_doc("Serial and Batch Bundle", row.get("name"))
doc.check_permission("write")
if doc.is_cancelled == 0 and not frappe.db.get_value(
"Stock Ledger Entry",
{"serial_and_batch_bundle": doc.name, "is_cancelled": 0},

View File

@@ -139,9 +139,7 @@ def get_items(filters):
item.brand,
item.stock_uom,
)
.where(
(IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1) & (pb.disabled == 0)
)
.where((IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1))
)
if item_code := filters.get("item_code"):
@@ -183,12 +181,7 @@ def get_items(filters):
pbi.uom,
pbi.qty,
)
.where(
pb.new_item_code.isin(parent_items)
& (pb.is_active == 1)
& (pb.docstatus == 1)
& (pb.disabled == 0)
)
.where(pb.new_item_code.isin(parent_items) & (pb.is_active == 1) & (pb.docstatus == 1))
).run(as_dict=1)
child_items = set()

View File

@@ -308,11 +308,6 @@ class FIFOSlots:
# prepare single sle voucher detail lookup
self.prepare_stock_reco_voucher_wise_count()
if stock_ledger_entries is None:
# nested queries invalidate the streaming cursor below,
# so batchwise valuation flags must be resolved beforehand
self._prefetch_batchwise_valuations()
with frappe.db.unbuffered_cursor():
if stock_ledger_entries is None:
stock_ledger_entries = self._get_stock_ledger_entries()
@@ -430,38 +425,12 @@ class FIFOSlots:
def _get_batchwise_valuation(self, batch_no: str):
if batch_no not in self.batchwise_valuation_by_batch:
# only reachable when stock ledger entries are passed in directly;
# the streaming path prefetches all flags before iteration
self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value(
"Batch", batch_no, "use_batchwise_valuation"
)
return self.batchwise_valuation_by_batch[batch_no]
def _prefetch_batchwise_valuations(self) -> None:
sle = frappe.qb.DocType("Stock Ledger Entry")
batch = frappe.qb.DocType("Batch")
to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
query = (
frappe.qb.from_(sle)
.left_join(batch)
.on(sle.batch_no == batch.name)
.select(sle.batch_no, batch.use_batchwise_valuation)
.distinct()
.where(
(sle.batch_no.isnotnull())
& (sle.company == self.filters.get("company"))
& (sle.posting_datetime <= to_date)
& (sle.is_cancelled != 1)
)
)
query = self._apply_filter(query, sle, "item_code")
for batch_no, use_batchwise_valuation in query.run():
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
def _init_key_stores(self, row: dict) -> tuple:
"Initialise keys and FIFO Queue."

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