mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-11 08:53:03 +00:00
Compare commits
32 Commits
copilot/fi
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3ec325e2 | ||
|
|
9c5f9218b5 | ||
|
|
a8a78a2163 | ||
|
|
0b6121422d | ||
|
|
9249fa89aa | ||
|
|
5a816d19cb | ||
|
|
a7d41f24a3 | ||
|
|
81a1c2c8ce | ||
|
|
0c6f7fed55 | ||
|
|
bfee9df9aa | ||
|
|
bddd1d0ebc | ||
|
|
aa9f225c41 | ||
|
|
9c799f31ff | ||
|
|
a60afaf91a | ||
|
|
a4cff805f1 | ||
|
|
4f55071eda | ||
|
|
43bb6c5a42 | ||
|
|
34955380ee | ||
|
|
1714e13b39 | ||
|
|
263c3e9dd4 | ||
|
|
c97c2d1e02 | ||
|
|
cf37478870 | ||
|
|
060a5c4eeb | ||
|
|
f099dbad35 | ||
|
|
cc8ce03232 | ||
|
|
bcc1e73962 | ||
|
|
32d7250946 | ||
|
|
4c1cabb53e | ||
|
|
1105cb8ddf | ||
|
|
8bb4ffc6b1 | ||
|
|
58582cfa09 | ||
|
|
1ef4978a86 |
@@ -409,18 +409,16 @@ 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: { args: args },
|
||||
args: {
|
||||
doctype: doctype,
|
||||
docname: docname,
|
||||
company: company,
|
||||
account: child.account,
|
||||
party: child.party,
|
||||
account_currency: child.account_currency,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
$.each(r.message, function (field, value) {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -43,6 +44,14 @@ 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.
|
||||
|
||||
@@ -128,6 +137,7 @@ 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,
|
||||
@@ -188,28 +198,33 @@ 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()
|
||||
@@ -221,18 +236,16 @@ class JournalEntry(AccountsController):
|
||||
JournalTaxWithholding(self).on_submit()
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_balance_for_periodic_accounting(self):
|
||||
def get_balance_for_periodic_accounting(self) -> None:
|
||||
"""Rebuild the entry rows from the stock-vs-ledger difference of each stock account."""
|
||||
self.validate_company_for_periodic_accounting()
|
||||
|
||||
stock_accounts = self.get_stock_accounts_for_periodic_accounting()
|
||||
self.set("accounts", [])
|
||||
for account in stock_accounts:
|
||||
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
|
||||
for account in self.get_stock_accounts_for_periodic_accounting():
|
||||
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)),
|
||||
@@ -240,23 +253,26 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
continue
|
||||
|
||||
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_periodic_difference_rows(account, difference_value)
|
||||
|
||||
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 _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,
|
||||
},
|
||||
)
|
||||
|
||||
def validate_company_for_periodic_accounting(self):
|
||||
if erpnext.is_perpetual_inventory_enabled(self.company):
|
||||
@@ -302,6 +318,7 @@ 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
|
||||
@@ -385,49 +402,44 @@ class JournalEntry(AccountsController):
|
||||
self.name,
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
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)
|
||||
|
||||
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 _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")
|
||||
)
|
||||
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)
|
||||
)
|
||||
)
|
||||
|
||||
def unlink_advance_entry_reference(self):
|
||||
for d in self.get("accounts"):
|
||||
@@ -543,62 +555,76 @@ class JournalEntry(AccountsController):
|
||||
self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency and self.is_system_generated
|
||||
)
|
||||
|
||||
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_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)
|
||||
|
||||
if d.reference_name == self.name:
|
||||
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
|
||||
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)
|
||||
|
||||
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,
|
||||
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)
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
def set_against_account(self):
|
||||
accounts_debited, accounts_credited = [], []
|
||||
@@ -686,131 +712,142 @@ 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):
|
||||
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_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))
|
||||
|
||||
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:
|
||||
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
|
||||
return
|
||||
|
||||
if self.get("custom_remark"):
|
||||
return
|
||||
|
||||
if self.cheque_no:
|
||||
if self.cheque_date:
|
||||
r.append(_("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date)))
|
||||
else:
|
||||
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"
|
||||
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 bank_amount:
|
||||
total_amount = bank_amount
|
||||
currency = bank_account_currency
|
||||
)
|
||||
if not needs_refresh or self.flags.get("ignore_exchange_rate"):
|
||||
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
|
||||
else:
|
||||
total_amount = party_amount
|
||||
currency = party_account_currency
|
||||
total_amount, currency = amounts.party_amount, amounts.party_account_currency
|
||||
|
||||
self.set_total_amount(total_amount, currency)
|
||||
|
||||
def set_total_amount(self, amt, 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:
|
||||
self.total_amount = amt
|
||||
self.total_amount_currency = currency
|
||||
from frappe.utils import money_in_words
|
||||
@@ -822,7 +859,7 @@ class JournalEntry(AccountsController):
|
||||
|
||||
return JournalEntryGLComposer(self).compose()
|
||||
|
||||
def make_gl_entries(self, cancel=0, adv_adj=0):
|
||||
def make_gl_entries(self, cancel: int = 0, adv_adj: int = 0) -> None:
|
||||
from erpnext.accounts.general_ledger import make_gl_entries
|
||||
|
||||
merge_entries = frappe.get_single_value("Accounts Settings", "merge_similar_account_heads")
|
||||
@@ -846,94 +883,109 @@ 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):
|
||||
def get_balance(self, difference_account: str | None = None) -> None:
|
||||
"""Balance the entry by placing any difference on a blank (or newly added) row."""
|
||||
if not self.get("accounts"):
|
||||
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
|
||||
else:
|
||||
self.total_debit, self.total_credit = 0, 0
|
||||
diff = flt(self.difference, self.precision("difference"))
|
||||
return
|
||||
|
||||
# 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.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 not blank_row:
|
||||
blank_row = self.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": difference_account,
|
||||
"cost_center": erpnext.get_default_cost_center(self.company),
|
||||
},
|
||||
)
|
||||
self.set_total_debit_credit()
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
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),
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_outstanding_invoices(self):
|
||||
def get_outstanding_invoices(self) -> None:
|
||||
"""Populate the entry with a write-off row per outstanding invoice plus a balancing row."""
|
||||
self.set("accounts", [])
|
||||
total = 0
|
||||
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
|
||||
for invoice in self.get_values():
|
||||
total += flt(invoice.outstanding_amount, self.precision("credit", "accounts"))
|
||||
self._append_outstanding_invoice_row(invoice)
|
||||
|
||||
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", {})
|
||||
balancing_row = self.append("accounts", {})
|
||||
if self.write_off_based_on == "Accounts Receivable":
|
||||
jd2.debit_in_account_currency = total
|
||||
balancing_row.debit_in_account_currency = total
|
||||
elif self.write_off_based_on == "Accounts Payable":
|
||||
jd2.credit_in_account_currency = total
|
||||
balancing_row.credit_in_account_currency = total
|
||||
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
def get_values(self):
|
||||
cond = (
|
||||
f" and outstanding_amount <= {flt(self.write_off_amount)}"
|
||||
if flt(self.write_off_amount) > 0
|
||||
else ""
|
||||
)
|
||||
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":
|
||||
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,
|
||||
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":
|
||||
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,
|
||||
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)
|
||||
)
|
||||
)
|
||||
if flt(self.write_off_amount) > 0:
|
||||
query = query.where(invoice.outstanding_amount <= flt(self.write_off_amount))
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
def validate_credit_debit_note(self):
|
||||
if self.stock_entry:
|
||||
@@ -962,7 +1014,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:
|
||||
@@ -1017,7 +1069,8 @@ 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 []
|
||||
|
||||
@@ -1048,67 +1101,97 @@ def get_against_jv(
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_outstanding(args: str | dict):
|
||||
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.
|
||||
"""
|
||||
if not frappe.has_permission("Account"):
|
||||
frappe.msgprint(_("No Permission"), raise_exception=1)
|
||||
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
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")
|
||||
|
||||
company_currency = erpnext.get_company_currency(args.get("company"))
|
||||
due_date = None
|
||||
if doctype == "Journal Entry":
|
||||
return _get_journal_entry_outstanding(docname, account, party)
|
||||
|
||||
if args.get("doctype") == "Journal Entry":
|
||||
condition = " and party=%(party)s" if args.get("party") else ""
|
||||
if doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||
return _get_invoice_outstanding(doctype, docname, company, 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,
|
||||
|
||||
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 = 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,
|
||||
}
|
||||
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"),
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_party_account_and_currency(company: str, party_type: str, party: str):
|
||||
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."""
|
||||
if not frappe.has_permission("Account"):
|
||||
frappe.msgprint(_("No Permission"), raise_exception=1)
|
||||
|
||||
@@ -1128,7 +1211,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)
|
||||
@@ -1186,7 +1269,8 @@ 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
|
||||
@@ -1219,14 +1303,3 @@ 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
|
||||
|
||||
@@ -24,7 +24,8 @@ 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:
|
||||
@@ -74,7 +75,8 @@ 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"
|
||||
@@ -110,32 +112,54 @@ def get_payment_entry_against_invoice(
|
||||
)
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import (
|
||||
get_default_bank_cash_account,
|
||||
get_exchange_rate,
|
||||
)
|
||||
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")})
|
||||
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
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,
|
||||
)
|
||||
exchange_rate = _reference_exchange_rate(ref_doc, args)
|
||||
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||
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)
|
||||
|
||||
party_row = je.append(
|
||||
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(
|
||||
"accounts",
|
||||
{
|
||||
"account": args.get("party_account"),
|
||||
@@ -153,14 +177,19 @@ def get_payment_entry(ref_doc, args):
|
||||
},
|
||||
)
|
||||
|
||||
bank_row = je.append("accounts")
|
||||
|
||||
# Make it bank_details
|
||||
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")
|
||||
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
|
||||
if bank_account:
|
||||
bank_row.update(bank_account)
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date of the reference date
|
||||
# posting date assumed to be the reference document's posting/transaction date
|
||||
bank_row.exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
bank_account["account"],
|
||||
@@ -171,26 +200,17 @@ def get_payment_entry(ref_doc, args):
|
||||
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)
|
||||
|
||||
# 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()
|
||||
return bank_row
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
|
||||
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`."""
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.voucher_type = voucher_type
|
||||
journal_entry.company = company
|
||||
@@ -200,7 +220,8 @@ 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):
|
||||
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)."""
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
@@ -211,7 +232,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):
|
||||
def post_process(source, target) -> None:
|
||||
target.reversal_of = source.name
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
|
||||
@@ -20,10 +20,11 @@ class AssetService:
|
||||
Journal Entries tied to asset scrapping or value adjustments.
|
||||
"""
|
||||
|
||||
def __init__(self, doc):
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self):
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
|
||||
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
|
||||
for d in self.doc.get("accounts"):
|
||||
if d.account_type == "Depreciation":
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
@@ -34,7 +35,8 @@ 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):
|
||||
def has_asset_adjustment_entry(self) -> None:
|
||||
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
|
||||
if self.doc.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
@@ -48,11 +50,13 @@ class AssetService:
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def update_asset_value(self):
|
||||
def update_asset_value(self) -> None:
|
||||
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
|
||||
self.update_asset_on_depreciation()
|
||||
self.update_asset_on_disposal()
|
||||
|
||||
def update_asset_on_depreciation(self):
|
||||
def update_asset_on_depreciation(self) -> None:
|
||||
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
return
|
||||
|
||||
@@ -73,7 +77,8 @@ class AssetService:
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def update_value_after_depreciation(self, asset, depr_amount):
|
||||
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
|
||||
"""Subtract the depreciation amount from the asset's relevant finance book."""
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
@@ -86,7 +91,8 @@ 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):
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
|
||||
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
@@ -96,7 +102,8 @@ class AssetService:
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
|
||||
|
||||
def update_asset_on_disposal(self):
|
||||
def update_asset_on_disposal(self) -> None:
|
||||
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
|
||||
if self.doc.voucher_type == "Asset Disposal":
|
||||
disposed_assets = []
|
||||
for d in self.doc.get("accounts"):
|
||||
@@ -117,62 +124,74 @@ class AssetService:
|
||||
asset_doc.set_status()
|
||||
disposed_assets.append(d.reference_name)
|
||||
|
||||
def unlink_asset_reference(self):
|
||||
def unlink_asset_reference(self) -> None:
|
||||
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
|
||||
for d in self.doc.get("accounts"):
|
||||
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()
|
||||
if self._is_depreciation_asset_row(d):
|
||||
self._reverse_asset_depreciation(d)
|
||||
elif (
|
||||
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
|
||||
):
|
||||
journal_entry_for_scrap = frappe.db.get_value(
|
||||
"Asset", d.reference_name, "journal_entry_for_scrap"
|
||||
)
|
||||
self._block_scrap_journal_cancel(d)
|
||||
|
||||
if journal_entry_for_scrap == self.doc.name:
|
||||
frappe.throw(
|
||||
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
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."""
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
|
||||
@@ -18,86 +18,88 @@ class JournalEntryGLComposer(BaseGLComposer):
|
||||
from the first foreign-currency row (mirroring the former build_gl_map).
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
def compose(self) -> list:
|
||||
"""Project the Journal Entry's non-zero account rows into GL dicts."""
|
||||
self._set_transaction_currency()
|
||||
advance_doctypes = get_advance_payment_doctypes()
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
return row
|
||||
|
||||
@@ -29,10 +29,11 @@ class JournalEntryReferenceValidator:
|
||||
orders and invoices.
|
||||
"""
|
||||
|
||||
def __init__(self, doc):
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def validate(self):
|
||||
def validate(self) -> None:
|
||||
"""Validate every reference-bearing row, then the referenced orders and invoices."""
|
||||
self.doc.reference_totals = {}
|
||||
self.doc.reference_types = {}
|
||||
self.doc.reference_accounts = {}
|
||||
@@ -47,23 +48,24 @@ class JournalEntryReferenceValidator:
|
||||
self._validate_orders()
|
||||
self._validate_invoices()
|
||||
|
||||
def _normalize_reference_fields(self, row):
|
||||
def _normalize_reference_fields(self, row) -> None:
|
||||
if not row.reference_type:
|
||||
row.reference_name = None
|
||||
if not row.reference_name:
|
||||
row.reference_type = None
|
||||
|
||||
def _has_party_reference(self, row):
|
||||
def _has_party_reference(self, row) -> bool:
|
||||
return bool(
|
||||
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
|
||||
)
|
||||
|
||||
def _reference_amount_field(self, row):
|
||||
def _reference_amount_field(self, row) -> str:
|
||||
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):
|
||||
def _validate_order_direction(self, row) -> None:
|
||||
"""An order can only be linked on the side that records an advance."""
|
||||
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)
|
||||
@@ -73,7 +75,8 @@ class JournalEntryReferenceValidator:
|
||||
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
|
||||
def _register_reference(self, row):
|
||||
def _register_reference(self, row) -> None:
|
||||
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
|
||||
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"):
|
||||
@@ -81,7 +84,8 @@ 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):
|
||||
def _validate_reference_party_and_account(self, row) -> None:
|
||||
"""Reject a missing reference, then check party/account against the linked document."""
|
||||
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]
|
||||
@@ -94,7 +98,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):
|
||||
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
|
||||
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
|
||||
if self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||
return
|
||||
@@ -105,7 +109,9 @@ class JournalEntryReferenceValidator:
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_invoice_party_account(self, row, against_voucher):
|
||||
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."""
|
||||
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(
|
||||
@@ -120,7 +126,7 @@ class JournalEntryReferenceValidator:
|
||||
party_account = against_voucher[1]
|
||||
return party_account, against_voucher[0]
|
||||
|
||||
def _validate_order_party(self, row, against_voucher):
|
||||
def _validate_order_party(self, row, against_voucher) -> None:
|
||||
if against_voucher != row.party:
|
||||
frappe.throw(
|
||||
_("Row {0}: {1} {2} does not match with {3}").format(
|
||||
@@ -128,8 +134,8 @@ class JournalEntryReferenceValidator:
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_orders(self):
|
||||
"""Validate totals, closed and docstatus for orders"""
|
||||
def _validate_orders(self) -> None:
|
||||
"""Validate totals, closed and docstatus for referenced 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]
|
||||
@@ -140,7 +146,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):
|
||||
def _validate_order_status(self, order, reference_type, reference_name) -> None:
|
||||
if order.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
if flt(order.per_billed) >= 100:
|
||||
@@ -148,7 +154,8 @@ 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):
|
||||
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."""
|
||||
account_currency = get_account_currency(account)
|
||||
if account_currency == self.doc.company_currency:
|
||||
voucher_total = order.base_grand_total
|
||||
@@ -167,8 +174,8 @@ class JournalEntryReferenceValidator:
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_invoices(self):
|
||||
"""Validate totals and docstatus for invoices"""
|
||||
def _validate_invoices(self) -> None:
|
||||
"""Validate totals and docstatus for referenced invoices."""
|
||||
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
|
||||
return
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
@@ -178,7 +185,8 @@ 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):
|
||||
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
|
||||
"""Payment booked against an invoice cannot exceed its outstanding amount."""
|
||||
if invoice.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
|
||||
@@ -169,8 +169,11 @@ 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",
|
||||
@@ -179,6 +182,8 @@ 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",
|
||||
@@ -187,6 +192,8 @@ 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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -203,6 +210,52 @@ 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
|
||||
|
||||
@@ -688,6 +741,95 @@ 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,
|
||||
|
||||
@@ -927,8 +927,28 @@ 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))
|
||||
|
||||
@@ -1243,3 +1243,44 @@ 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)
|
||||
|
||||
@@ -425,7 +425,12 @@ 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},
|
||||
filters={
|
||||
"new_item_code": ("in", items_to_fetch),
|
||||
"is_active": 1,
|
||||
"docstatus": 1,
|
||||
"disabled": 0,
|
||||
},
|
||||
fields="new_item_code",
|
||||
)
|
||||
}
|
||||
@@ -979,9 +984,14 @@ class SellingController(StockController):
|
||||
|
||||
qty_can_be_deliver = 0
|
||||
if sre_doc.reservation_based_on == "Serial and Batch":
|
||||
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
|
||||
# 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)
|
||||
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.
|
||||
@@ -989,16 +999,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:
|
||||
if entry.batch_no in delivered_batch_qty:
|
||||
available_batch_qty = delivered_batch_qty.get(entry.batch_no, 0)
|
||||
if available_batch_qty > 0:
|
||||
delivered_qty = min(
|
||||
(entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no]
|
||||
(entry.qty - entry.delivered_qty), available_batch_qty
|
||||
)
|
||||
entry.delivered_qty += delivered_qty
|
||||
entry.db_update()
|
||||
qty_can_be_deliver += delivered_qty
|
||||
delivered_batch_qty[entry.batch_no] -= delivered_qty
|
||||
delivered_batch_qty[entry.batch_no] = available_batch_qty - delivered_qty
|
||||
else:
|
||||
# `Delivered Qty` should be less than or equal to `Reserved Qty`.
|
||||
qty_can_be_deliver = min(
|
||||
@@ -1174,3 +1184,31 @@ 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
|
||||
|
||||
@@ -175,6 +175,9 @@ 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) {
|
||||
@@ -583,7 +586,7 @@ frappe.ui.form.on("BOM", {
|
||||
},
|
||||
|
||||
routing(frm) {
|
||||
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
|
||||
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "get_routing",
|
||||
|
||||
@@ -116,17 +116,57 @@ 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."""
|
||||
if isinstance(doc, str):
|
||||
doc = parse_json(doc)
|
||||
doc = frappe.get_doc("Production Plan", doc.get("name"))
|
||||
|
||||
doc = _load_production_plan(doc)
|
||||
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
|
||||
reserve_stock_for_production_plan(doc, items=items, table_name=table_name, notify=notify)
|
||||
ProductionPlanStockReservation(doc).reserve(items=items, table_name=table_name, notify=notify)
|
||||
|
||||
|
||||
def reserve_stock_for_production_plan(
|
||||
@@ -134,35 +174,19 @@ 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)."""
|
||||
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()
|
||||
ProductionPlanStockReservation(doc).reserve(items=items, table_name=table_name, notify=notify)
|
||||
|
||||
|
||||
@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"))
|
||||
|
||||
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
|
||||
sre = StockReservation(doc)
|
||||
sre.cancel_stock_reservation_entries(sre_list)
|
||||
|
||||
doc.reload()
|
||||
return doc
|
||||
|
||||
@@ -2322,6 +2322,74 @@ 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
|
||||
|
||||
@@ -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.stock_reservation import (
|
||||
StockReservationService,
|
||||
from erpnext.manufacturing.doctype.work_order.services.reservation import (
|
||||
WorkOrderStockReservation,
|
||||
get_consumed_qty,
|
||||
get_row_wise_serial_batch,
|
||||
)
|
||||
@@ -44,7 +44,7 @@ class RequiredItemsService:
|
||||
# update in bin
|
||||
self.update_reserved_qty_for_production()
|
||||
|
||||
StockReservationService(self.doc).validate_reserved_qty()
|
||||
WorkOrderStockReservation(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:
|
||||
StockReservationService(self.doc).update_qty_in_stock_reservation(
|
||||
WorkOrderStockReservation(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
|
||||
StockReservationService(self.doc).update_consumed_qty_in_stock_reservation(
|
||||
WorkOrderStockReservation(self.doc).update_consumed_qty_in_stock_reservation(
|
||||
item, consumed_qty, warehouse
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
"""Stock reservation logic for Work Order.
|
||||
|
||||
Extracted from work_order.py. ``StockReservationService`` wraps a Work Order
|
||||
Extracted from work_order.py. ``WorkOrderStockReservation`` 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 StockReservationService:
|
||||
class WorkOrderStockReservation:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
@@ -94,6 +94,11 @@ class StockReservationService:
|
||||
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={
|
||||
@@ -101,9 +106,10 @@ class StockReservationService:
|
||||
"item_code": row.item_code,
|
||||
"voucher_detail_no": row.name,
|
||||
"warehouse": row.source_warehouse,
|
||||
"status": ("not in", ["Closed", "Cancelled", "Completed"]),
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="name",
|
||||
order_by="creation",
|
||||
)
|
||||
for name in names:
|
||||
transferred_qty = self._apply_transferred_qty(name, transferred_qty, row_wise_serial_batch)
|
||||
@@ -424,14 +430,26 @@ class StockReservationService:
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
if sre_list:
|
||||
unreserve_stock_for_work_order(self.doc, sre_list)
|
||||
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())
|
||||
|
||||
def release_reserved_qty_for_subcontract_transfer(self):
|
||||
"""Free this Work Order's own reservation for items sent to a subcontractor.
|
||||
@@ -3417,6 +3417,137 @@ 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
|
||||
|
||||
@@ -46,11 +46,8 @@ 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.status import (
|
||||
StatusService,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
|
||||
StockReservationService,
|
||||
from erpnext.manufacturing.doctype.work_order.services.reservation import (
|
||||
WorkOrderStockReservation,
|
||||
cancel_stock_reservation_entries,
|
||||
get_consumed_qty,
|
||||
get_reserved_qty_for_production,
|
||||
@@ -58,6 +55,9 @@ from erpnext.manufacturing.doctype.work_order.services.stock_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()
|
||||
StockReservationService(self).set_reserve_stock()
|
||||
StockReservationService(self).validate_fg_warehouse_for_reservation()
|
||||
WorkOrderStockReservation(self).set_reserve_stock()
|
||||
WorkOrderStockReservation(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")))
|
||||
|
||||
StockReservationService(self).enable_auto_reserve_stock()
|
||||
WorkOrderStockReservation(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:
|
||||
StockReservationService(self).update_stock_reservation()
|
||||
WorkOrderStockReservation(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:
|
||||
StockReservationService(self).update_stock_reservation()
|
||||
WorkOrderStockReservation(self).update_stock_reservation()
|
||||
|
||||
self.update_subcontracting_inward_order_received_items()
|
||||
|
||||
|
||||
@@ -607,6 +607,9 @@ 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",
|
||||
@@ -625,7 +628,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: {
|
||||
item_code: args.product_bundle,
|
||||
product_bundle: args.product_bundle,
|
||||
quantity: args.quantity,
|
||||
parenttype: frm.doc.doctype,
|
||||
parent: frm.doc.name,
|
||||
|
||||
@@ -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 submitted Product Bundles of the row's item
|
||||
// restrict the version picker to enabled, 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,6 +209,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
filters: {
|
||||
new_item_code: row.item_code,
|
||||
docstatus: 1,
|
||||
disabled: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -76,13 +76,14 @@
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "0",
|
||||
"depends_on": "disabled",
|
||||
"description": "Deprecated: use Cancel / Is Active instead. Retained for backward compatibility.",
|
||||
"description": "A disabled Product Bundle cannot be selected in transactions.",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Disabled",
|
||||
"read_only": 1
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
@@ -102,7 +103,7 @@
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-08 00:00:00.000000",
|
||||
"modified": "2026-06-10 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Product Bundle",
|
||||
|
||||
@@ -62,8 +62,10 @@ class ProductBundle(Document):
|
||||
self.db_set("is_active", 0)
|
||||
|
||||
def on_update_after_submit(self):
|
||||
# `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.
|
||||
# `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.
|
||||
if self.is_active:
|
||||
self.make_active()
|
||||
|
||||
@@ -171,17 +173,19 @@ 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, submitted Product Bundle for ``item_code``, else None.
|
||||
"""Return the name of the active, enabled, 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.
|
||||
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.
|
||||
"""
|
||||
if not item_code:
|
||||
return None
|
||||
return frappe.db.get_value(
|
||||
"Product Bundle",
|
||||
{"new_item_code": item_code, "is_active": 1, "docstatus": 1},
|
||||
{"new_item_code": item_code, "is_active": 1, "docstatus": 1, "disabled": 0},
|
||||
"name",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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
|
||||
},
|
||||
};
|
||||
@@ -103,6 +103,38 @@ 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
|
||||
|
||||
@@ -376,12 +376,27 @@ def make_delivery_note(
|
||||
dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1))
|
||||
dn_item.warehouse = sre.warehouse
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
target_doc.append("items", dn_item)
|
||||
# Correct rows index.
|
||||
|
||||
@@ -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()
|
||||
StockReservationService(self).validate_reserved_stock()
|
||||
SalesOrderStockReservation(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"):
|
||||
StockReservationService(self).enable_auto_reserve_stock()
|
||||
SalesOrderStockReservation(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):
|
||||
StockReservationService(self).update_reserved_qty(so_item_rows)
|
||||
SalesOrderStockReservation(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 StockReservationService(self).has_unreserved_stock(table_name)
|
||||
return SalesOrderStockReservation(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."""
|
||||
StockReservationService(self).create_stock_reservation_entries(
|
||||
SalesOrderStockReservation(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."""
|
||||
StockReservationService(self).cancel_stock_reservation_entries(sre_list, notify)
|
||||
SalesOrderStockReservation(self).cancel_stock_reservation_entries(sre_list, notify)
|
||||
|
||||
def set_missing_values(self, for_validate=False):
|
||||
super().set_missing_values(for_validate)
|
||||
|
||||
@@ -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 StockReservationService:
|
||||
class SalesOrderStockReservation:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
@@ -13,6 +13,8 @@ def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
validate_filters(filters)
|
||||
|
||||
columns = get_columns(filters)
|
||||
entries = get_entries(filters)
|
||||
item_details = get_item_details()
|
||||
@@ -49,10 +51,17 @@ def execute(filters=None):
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_columns(filters):
|
||||
def validate_filters(filters):
|
||||
ALLOWED_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note"]
|
||||
|
||||
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"]),
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
|
||||
import frappe
|
||||
import frappe.defaults
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt
|
||||
|
||||
@@ -174,30 +175,6 @@ 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")
|
||||
@@ -219,14 +196,25 @@ 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.
|
||||
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.
|
||||
"""
|
||||
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"], as_dict=True)
|
||||
bundle = frappe.db.get_value(
|
||||
"Product Bundle", chosen, ["new_item_code", "docstatus", "disabled"], 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)
|
||||
@@ -445,9 +433,31 @@ 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)), []
|
||||
|
||||
bundled_items = get_product_bundle_items(row["item_code"])
|
||||
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 []
|
||||
for item in bundled_items:
|
||||
row.update(
|
||||
{
|
||||
|
||||
@@ -165,6 +165,80 @@ 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."
|
||||
|
||||
@@ -15,7 +15,9 @@ 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.stock_reservation import StockReservationService
|
||||
from erpnext.stock.doctype.purchase_receipt.services.reservation import (
|
||||
PurchaseReceiptStockReservation,
|
||||
)
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
|
||||
@@ -374,7 +376,7 @@ class PurchaseReceipt(BuyingController):
|
||||
self.make_gl_entries()
|
||||
self.repost_future_sle_and_gle()
|
||||
self.set_consumed_qty_in_subcontract_order()
|
||||
StockReservationService(self).reserve_stock()
|
||||
PurchaseReceiptStockReservation(self).reserve_stock()
|
||||
self.update_received_qty_if_from_pp()
|
||||
|
||||
def update_received_qty_if_from_pp(self):
|
||||
|
||||
@@ -10,7 +10,7 @@ from frappe.utils import get_datetime
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
|
||||
|
||||
|
||||
class StockReservationService:
|
||||
class PurchaseReceiptStockReservation:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
@@ -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.stock_reservation import (
|
||||
StockReservationService,
|
||||
from erpnext.manufacturing.doctype.work_order.services.reservation import (
|
||||
WorkOrderStockReservation,
|
||||
)
|
||||
|
||||
if self.is_stock_reserve_for_work_order():
|
||||
@@ -1076,7 +1076,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
):
|
||||
return
|
||||
|
||||
StockReservationService(pro_doc).set_reserved_qty_for_wip_and_fg(self)
|
||||
WorkOrderStockReservation(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.stock_reservation import (
|
||||
StockReservationService,
|
||||
from erpnext.manufacturing.doctype.work_order.services.reservation import (
|
||||
WorkOrderStockReservation,
|
||||
)
|
||||
|
||||
if self.is_stock_reserve_for_work_order():
|
||||
@@ -1116,7 +1116,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
):
|
||||
return
|
||||
|
||||
StockReservationService(pro_doc).cancel_reserved_qty_for_wip_and_fg(self)
|
||||
WorkOrderStockReservation(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.stock_reservation import (
|
||||
StockReservationService,
|
||||
from erpnext.manufacturing.doctype.work_order.services.reservation import (
|
||||
WorkOrderStockReservation,
|
||||
)
|
||||
|
||||
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)
|
||||
StockReservationService(pro_doc).release_reserved_qty_for_subcontract_transfer()
|
||||
WorkOrderStockReservation(pro_doc).release_reserved_qty_for_subcontract_transfer()
|
||||
|
||||
def get_reserved_work_orders_for_subcontracting(self):
|
||||
job_cards = set()
|
||||
|
||||
@@ -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.stock_reservation import get_unreserved_qty
|
||||
from erpnext.selling.doctype.sales_order.services.reservation import get_unreserved_qty
|
||||
|
||||
if not from_voucher_type and (
|
||||
sales_order.get("_action") == "submit"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from random import randint
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from frappe.utils import flt, 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,6 +313,231 @@ 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",
|
||||
{
|
||||
|
||||
@@ -139,7 +139,9 @@ def get_items(filters):
|
||||
item.brand,
|
||||
item.stock_uom,
|
||||
)
|
||||
.where((IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1))
|
||||
.where(
|
||||
(IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1) & (pb.disabled == 0)
|
||||
)
|
||||
)
|
||||
|
||||
if item_code := filters.get("item_code"):
|
||||
@@ -181,7 +183,12 @@ def get_items(filters):
|
||||
pbi.uom,
|
||||
pbi.qty,
|
||||
)
|
||||
.where(pb.new_item_code.isin(parent_items) & (pb.is_active == 1) & (pb.docstatus == 1))
|
||||
.where(
|
||||
pb.new_item_code.isin(parent_items)
|
||||
& (pb.is_active == 1)
|
||||
& (pb.docstatus == 1)
|
||||
& (pb.disabled == 0)
|
||||
)
|
||||
).run(as_dict=1)
|
||||
|
||||
child_items = set()
|
||||
|
||||
@@ -308,6 +308,11 @@ 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()
|
||||
@@ -425,12 +430,38 @@ 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."
|
||||
|
||||
|
||||
@@ -1434,6 +1434,80 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]]
|
||||
)
|
||||
|
||||
def test_legacy_batch_no_sle_with_streaming_cursor(self):
|
||||
"""SLEs carrying the legacy batch_no field must not trigger nested
|
||||
queries while entries stream through an unbuffered cursor."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
suffix = frappe.generate_hash(length=8).upper()
|
||||
item_code = make_item(
|
||||
f"Test Stock Ageing Legacy Batch {suffix}",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": f"SA-LEG-{suffix}-.###",
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
base_date = nowdate()
|
||||
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=10,
|
||||
posting_date=add_days(base_date, -2),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=10,
|
||||
batch_no=batch_no,
|
||||
posting_date=add_days(base_date, -1),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
|
||||
# mimic pre-bundle data where SLEs carry batch_no directly
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
{"item_code": item_code},
|
||||
"batch_no",
|
||||
batch_no,
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
to_date=base_date,
|
||||
ranges=["30", "60", "90"],
|
||||
item_code=item_code,
|
||||
)
|
||||
fifo_slots = FIFOSlots(filters)
|
||||
|
||||
# fetch row by row so the streaming result set is still active
|
||||
# while each stock ledger entry is processed
|
||||
with patch("frappe.database.database.SQL_ITERATOR_BATCH_SIZE", 1):
|
||||
slots = fifo_slots.generate()
|
||||
|
||||
self.assertEqual(fifo_slots.batchwise_valuation_by_batch.get(batch_no), 1)
|
||||
self.assertEqual(slots[item_code]["total_qty"], 5.0)
|
||||
|
||||
|
||||
def generate_item_and_item_wh_wise_slots(filters, sle):
|
||||
"Return results with and without 'show_warehouse_wise_stock'"
|
||||
|
||||
Reference in New Issue
Block a user