Compare commits

..

1 Commits

Author SHA1 Message Date
Mihir Kandoi
14fec2c154 test(postgres): probe whether MAX() over text agrees across engines
DO NOT MERGE -- this exists to make CI answer a question.

The parity effort wrapped many descriptive text columns in Max() to satisfy
strict GROUP BY, justified as "Max() returns the value MariaDB picked
arbitrarily". Where the column genuinely varies within its group that does not
hold: Max() over text is a sort, and the engines sort text differently.

MariaDB's utf8mb4 collations fold case but treat punctuation and spaces as
significant. glibc's en_US.UTF-8 -- what the Linux CI Postgres runs -- ignores
punctuation at the primary level, so max('ITEM-C', 'ITEMB') should be 'ITEMB'
on MariaDB and 'ITEM-C' on Postgres.

These assertions encode MariaDB's answers. They pass on macOS (BSD/ICU
collation, which happens to agree). If the Linux Postgres job fails them, the
Max()-over-varying-text sites are a live parity gap and not only a
row-coherence one.
2026-08-02 20:16:14 +05:30
222 changed files with 52263 additions and 143241 deletions

View File

@@ -170,13 +170,6 @@ audit of these fixes found four recurring mistakes:
- **Fabricated arithmetic** — `Sum(x) * Max(y)` where `y` varies within the group invents a
number no row ever had (and `Max` biases it upward) — poisonous when it feeds validation,
budgets, valuation, or GL/stock values. Fix per-row: `Sum(x * y)`.
- **Collation-dependent pick (text columns)** — `Max()`/`Min()` over text is a *sort*, and the two
engines sort text differently: MariaDB's `utf8mb4` collations fold case, PostgreSQL (as CI runs
it) orders by byte value. `MAX('abc', 'ABD')` is `ABD` on MariaDB and `abc` on PostgreSQL. So a
`Max()` over a text column that varies **in case** within its group is a live P2 divergence, not
the arbitrary-pick preservation the wrap is usually justified as. Confirmed on CI; see #56241.
Note a local macOS PostgreSQL gives a **false all-clear** — its collation happens to agree with
MariaDB on case. Fix: take a representative row rather than sorting text.
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
average for a rate. A blind `Max` can understate urgency or overstate a figure.

View File

@@ -171,30 +171,7 @@ jobs:
update_to_version 16 3.14
echo "Updating to latest version"
fallback_to_develop=0
if [ -n "${GITHUB_BASE_REF:-}" ]; then
frappe_ref="refs/heads/$GITHUB_BASE_REF"
fallback_to_develop=1
elif [ "${GITHUB_REF_TYPE:-}" = "branch" ]; then
frappe_ref="$GITHUB_REF"
fallback_to_develop=1
elif [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
frappe_ref="$GITHUB_REF"
else
echo "Unsupported GitHub ref type: '${GITHUB_REF_TYPE:-unset}'"
exit 1
fi
ls_remote_status=0
git -C "apps/frappe" ls-remote --exit-code upstream "$frappe_ref" >/dev/null \
|| ls_remote_status=$?
if [ "$ls_remote_status" -eq 2 ] && [ "$fallback_to_develop" -eq 1 ]; then
echo "frappe has no '$frappe_ref'; falling back to develop"
frappe_ref=refs/heads/develop
elif [ "$ls_remote_status" -ne 0 ]; then
exit "$ls_remote_status"
fi
git -C "apps/frappe" fetch --depth 1 upstream "$frappe_ref"
git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
git -C "apps/frappe" checkout -q -f FETCH_HEAD
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"

View File

@@ -23,5 +23,3 @@ jobs:
steps:
- uses: alyf-de/po-review-action@v1.1.0
with:
hidden-po-files: eo.po

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,6 @@ erpnext/accounts/ @ruthra-kumar
erpnext/assets/ @khushi8112
erpnext/regional @ruthra-kumar
erpnext/selling @ruthra-kumar
banking/ @nikkothari22
erpnext/buying/ @rohitwaghchaure @mihir-kandoi
erpnext/maintenance/ @rohitwaghchaure @mihir-kandoi

View File

@@ -19,22 +19,13 @@ import {
import { cn } from "@/lib/utils"
import _ from "@/lib/translate"
import { selectedBankAccountAtom } from "./bankRecAtoms"
import { useFrappeGetDocList } from "frappe-react-sdk"
import ErrorBanner from "@/components/ui/error-banner"
const CompanySelector = ({ onChange }: { onChange?: (company: string) => void }) => {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const { data: companies, error } = useFrappeGetDocList("Company", {
limit: 0,
fields: ["name"],
}, 'company_list', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
})
const options = companies?.map((company: { name: string }) => company.name) || []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = window.frappe?.boot?.docs?.filter((doc: Record<string, any>) => doc.doctype === ":Company").map((company: Record<string, any>) => company.name) || []
const setSelectedCompany = useSetAtom(selectedCompanyAtom)
const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom)
@@ -51,10 +42,6 @@ const CompanySelector = ({ onChange }: { onChange?: (company: string) => void })
}
}
if (error) {
return <ErrorBanner error={error} />
}
return (<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button

View File

@@ -1,9 +1,7 @@
import { useAtomValue } from "jotai"
import { atomWithStorage } from "jotai/utils"
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '', undefined, {
getOnInit: true,
})
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '')
export const useCurrentCompany = () => {
const selectedCompany = useAtomValue(selectedCompanyAtom)

View File

@@ -730,8 +730,6 @@ def get_company_default_account_fields():
"default_discount_account": "Default Payment Discount Account",
"unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
"exchange_gain_loss_account": "Exchange Gain / Loss Account",
"exchange_gain_account": "Exchange Gain Account",
"exchange_loss_account": "Exchange Loss Account",
"unrealized_exchange_gain_loss_account": "Unrealized Exchange Gain / Loss Account",
"round_off_account": "Round Off Account",
"default_deferred_revenue_account": "Default Deferred Revenue Account",

View File

@@ -179,9 +179,6 @@
},
"Impairment": {
"account_category": "Operating Expenses"
},
"Exchange Loss": {
"account_category": "Operating Expenses"
}
},
"root_type": "Expense"
@@ -199,10 +196,6 @@
"account_type": "Income Account"
},
"Indirect Income": {
"Exchange Gain": {
"account_type": "Income Account",
"account_category": "Other Operating Income"
},
"account_type": "Income Account",
"is_group": 1
},

View File

@@ -138,7 +138,6 @@ def get():
_("Gain/Loss on Asset Disposal"): {"account_category": "Other Operating Income"},
_("Impairment"): {"account_category": "Operating Expenses"},
_("Tax Expense"): {"account_category": "Tax Expense"},
_("Exchange Loss"): {"account_category": "Operating Expenses"},
},
"root_type": "Expense",
},
@@ -150,7 +149,6 @@ def get():
_("Indirect Income"): {
_("Interest Income"): {"account_category": "Investment Income"},
_("Interest on Fixed Deposits"): {"account_category": "Investment Income"},
_("Exchange Gain"): {"account_category": "Other Operating Income"},
"is_group": 1,
},
"root_type": "Income",

View File

@@ -233,7 +233,6 @@ def get():
},
_("Impairment"): {"account_number": "5224", "account_category": "Operating Expenses"},
_("Tax Expense"): {"account_number": "5225", "account_category": "Tax Expense"},
_("Exchange Loss"): {"account_number": "5226", "account_category": "Operating Expenses"},
"account_number": "5200",
},
"root_type": "Expense",
@@ -251,10 +250,6 @@ def get():
"account_number": "4220",
"account_category": "Investment Income",
},
_("Exchange Gain"): {
"account_number": "4230",
"account_category": "Other Operating Income",
},
"is_group": 1,
"account_number": "4200",
},

View File

@@ -729,7 +729,6 @@ def get_ordered_amount(params):
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.amount > child.billed_amt)
& (child.closed == 0)
& (parent.status != "Closed")
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
)

View File

@@ -275,7 +275,6 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
.join(overdue_payment)
.on(overdue_payment.parent == dunning.name)
.select(dunning.name)
.distinct()
.where(
(dunning.status == state)
& (dunning.docstatus != 2)

View File

@@ -123,41 +123,6 @@ class TestDunning(ERPNextTestSuite):
self.assertEqual(sales_invoice.status, "Overdue")
self.assertEqual(dunning.status, "Unresolved")
def test_payment_against_invoice_with_multiple_overdue_installments_in_dunning(self):
"""
When an invoice has more than one overdue installment, its Dunning holds one
Overdue Payment row per installment. Submitting a Payment Entry for the invoice
must resolve the Dunning without raising a TimestampMismatchError caused by the
same Dunning being loaded and saved more than once.
"""
create_payment_terms_template_for_dunning()
# Post far enough in the past that BOTH installments (5 and 10 credit days) are overdue.
sales_invoice = create_sales_invoice_against_cost_center(
posting_date=add_days(today(), -15),
qty=1,
rate=100,
do_not_submit=True,
)
sales_invoice.payment_terms_template = "_Test 50-50 for Dunning"
sales_invoice.submit()
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
# Two overdue installments -> two overdue payment rows for the same invoice.
self.assertEqual(len(dunning.overdue_payments), 2)
dunning.submit()
self.assertEqual(dunning.status, "Unresolved")
# Pay the invoice in full. This previously raised TimestampMismatchError on the Dunning.
pe = get_payment_entry("Sales Invoice", sales_invoice.name)
pe.reference_no, pe.reference_date = "3", nowdate()
pe.insert()
pe.submit()
sales_invoice.reload()
dunning.reload()
self.assertEqual(sales_invoice.outstanding_amount, 0)
self.assertEqual(dunning.status, "Resolved")
def test_dunning_resolution_from_credit_note(self):
"""
Test that dunning is resolved when a credit note is issued against the original invoice.

View File

@@ -235,7 +235,7 @@ Object.assign(erpnext.journal_entry, {
lock_reversal_entry(frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => !["posting_date", "custom_remark", "remark"].includes(field.df.fieldname))
.filter((field) => field.df.fieldname != "posting_date")
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -184,7 +184,6 @@ class JournalEntryReferenceValidator:
continue
invoice = frappe.get_doc(reference_type, reference_name)
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
self._validate_block_invoice(invoice)
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
"""Payment booked against an invoice cannot exceed its outstanding amount."""
@@ -198,15 +197,3 @@ class JournalEntryReferenceValidator:
reference_type, reference_name, invoice.outstanding_amount
)
)
def _validate_block_invoice(self, invoice):
"""Payment cannnot be booked against blocked Purchase Invoices"""
if invoice.doctype != "Purchase Invoice":
return
if invoice.invoice_is_blocked():
frappe.throw(
_("{0} {1} is blocked and on hold until {2}.").format(
invoice.doctype, invoice.name, invoice.release_date
)
)

View File

@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import add_days, flt, nowdate
from frappe.utils import flt, nowdate
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction
@@ -748,69 +748,6 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
def make_jv_against_purchase_invoice(self, invoice, amount=100):
jv = make_journal_entry("Creditors - _TC", "_Test Cash - _TC", amount, save=False)
jv.accounts[0].party_type = "Supplier"
jv.accounts[0].party = invoice.supplier
jv.accounts[0].reference_type = "Purchase Invoice"
jv.accounts[0].reference_name = invoice.name
return jv
def test_jv_against_purchase_invoice_respects_hold_state(self):
"""Payment can be booked against a Purchase Invoice only while it is not on hold."""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
release_date = add_days(nowdate(), 10)
def never_held():
return make_purchase_invoice()
def held_until_a_future_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
return invoice
def held_without_a_release_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Under dispute")
return invoice
def held_until_a_date_that_has_passed():
invoice = held_until_a_future_date()
frappe.db.set_value("Purchase Invoice", invoice.name, "release_date", add_days(nowdate(), -1))
return invoice
def unblocked_again():
invoice = held_until_a_future_date()
invoice.unblock_invoice()
return invoice
for build_invoice in (held_until_a_future_date, held_without_a_release_date):
with self.subTest(build_invoice.__name__):
jv = self.make_jv_against_purchase_invoice(build_invoice())
self.assertRaisesRegex(frappe.ValidationError, "is blocked and on hold until", jv.insert)
for build_invoice in (never_held, held_until_a_date_that_has_passed, unblocked_again):
with self.subTest(build_invoice.__name__):
invoice = build_invoice()
jv = self.make_jv_against_purchase_invoice(invoice)
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Purchase Invoice")
def test_jv_against_blocked_sales_invoice_reference_is_not_checked(self):
"""A Sales Invoice has no hold state, so the check must skip it rather than fail."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=500)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = "_Test Customer"
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = invoice.name
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
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")

View File

@@ -950,61 +950,6 @@ class TestPaymentEntry(ERPNextTestSuite):
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 0)
def test_exchange_gain_loss_split_accounts(self):
gain_account = create_account(
account_name="_Test Exchange Gain",
parent_account="Indirect Expenses - _TC",
company="_Test Company",
)
loss_account = create_account(
account_name="_Test Exchange Loss",
parent_account="Indirect Expenses - _TC",
company="_Test Company",
)
frappe.db.set_value("Company", "_Test Company", "exchange_gain_account", gain_account)
frappe.db.set_value("Company", "_Test Company", "exchange_loss_account", loss_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_gain_account", "")
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_loss_account", "")
si_gain = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=50,
)
pe_gain = get_payment_entry("Sales Invoice", si_gain.name, bank_account="_Test Bank USD - _TC")
pe_gain.reference_no = "1"
pe_gain.reference_date = "2016-01-01"
pe_gain.source_exchange_rate = 55
pe_gain.save()
self.assertEqual(pe_gain.references[0].exchange_gain_loss, 500)
pe_gain.submit()
self.assertEqual(self.get_gain_loss_journal_account(pe_gain.name), gain_account)
si_loss = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=55,
)
pe_loss = get_payment_entry("Sales Invoice", si_loss.name, bank_account="_Test Bank USD - _TC")
pe_loss.reference_no = "2"
pe_loss.reference_date = "2016-01-01"
pe_loss.source_exchange_rate = 50
pe_loss.save()
self.assertEqual(pe_loss.references[0].exchange_gain_loss, -500)
pe_loss.submit()
self.assertEqual(self.get_gain_loss_journal_account(pe_loss.name), loss_account)
def get_gain_loss_journal_account(self, payment_entry_name: str) -> str | None:
return frappe.db.get_value(
"Journal Entry Account",
{"reference_type": "Payment Entry", "reference_name": payment_entry_name, "docstatus": 1},
"account",
)
def test_payment_entry_against_sales_invoice_with_cost_centre(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center

View File

@@ -18,7 +18,6 @@ from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_rec
is_any_doc_running,
)
from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional
from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account
from erpnext.accounts.utils import (
QueryPaymentLedger,
create_gain_loss_journal,
@@ -486,6 +485,9 @@ class PaymentReconciliation(Document):
"Accounts Settings", "exchange_gain_loss_posting_date", cache=True
)
invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments"))
default_exchange_gain_loss_account = frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
)
entries = []
for pay in args.get("payments"):
@@ -505,10 +507,7 @@ class PaymentReconciliation(Document):
pay["exchange_rate"] = invoice_exchange_map.get(pay.get("reference_name"))
res.difference_amount = self.get_difference_amount(pay, inv, res["allocated_amount"])
is_gain = (
res.difference_amount > 0 if self.party_type == "Customer" else res.difference_amount < 0
)
res.difference_account = get_exchange_gain_loss_account(self.company, is_gain)
res.difference_account = default_exchange_gain_loss_account
res.exchange_rate = inv.get("exchange_rate")
res.update({"gain_loss_posting_date": pay.get("posting_date")})
if not pay.get("is_advance"):

View File

@@ -6,7 +6,6 @@ import frappe
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
from frappe.utils.data import getdate as convert_to_date
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
@@ -188,150 +187,6 @@ class TestPaymentReconciliation(ERPNextTestSuite):
)
return je
def setup_split_exchange_accounts(self):
gain_account = create_account(
account_name="_Test PR Split Exchange Gain",
parent_account="Indirect Expenses - _TC",
company=self.company,
)
loss_account = create_account(
account_name="_Test PR Split Exchange Loss",
parent_account="Indirect Expenses - _TC",
company=self.company,
)
frappe.db.set_value("Company", self.company, "exchange_gain_account", gain_account)
frappe.db.set_value("Company", self.company, "exchange_loss_account", loss_account)
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_gain_account", "")
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_loss_account", "")
return gain_account, loss_account
def create_foreign_currency_sales_invoice(self, conversion_rate):
si = self.create_sales_invoice(
qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
si.customer = self.customer_usd
si.currency = "USD"
si.conversion_rate = conversion_rate
si.debit_to = self.debtors_usd
si.save().submit()
return si
def create_foreign_currency_journal_payment(self, debtors_account, exchange_rate):
je = self.create_journal_entry(self.bank, debtors_account, 100, nowdate())
je.multi_currency = 1
je.accounts[0].exchange_rate = 1
je.accounts[0].credit_in_account_currency = 0
je.accounts[0].credit = 0
je.accounts[0].debit_in_account_currency = 100 * exchange_rate
je.accounts[0].debit = 100 * exchange_rate
je.accounts[1].party_type = "Customer"
je.accounts[1].party = self.customer_usd
je.accounts[1].exchange_rate = exchange_rate
je.accounts[1].credit_in_account_currency = 100
je.accounts[1].credit = 100 * exchange_rate
je.accounts[1].debit_in_account_currency = 0
je.accounts[1].debit = 0
je.save()
je.submit()
return je
def test_voucher_outstanding_metadata_comes_from_one_ledger_entry(self):
"""cost_center and remarks must describe the same Payment Ledger Entry.
A voucher can post several ledger entries for one party with different cost centers and
remarks. Aggregating each column on its own can pair one entry's cost center with another's
remarks -- a row that was never posted -- and because Max() over text is a sort, MariaDB and
PostgreSQL can pick differently on top of that.
"""
from erpnext.accounts.utils import QueryPaymentLedger
je = frappe.new_doc("Journal Entry")
je.posting_date = nowdate()
je.company = self.company
je.user_remark = "aaa base remark"
for cost_center, remark, amount in (
(self.main_cc, "aaa main line", 100),
(self.sub_cc, "zzz sub line", 50),
):
je.append(
"accounts",
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"cost_center": cost_center,
"user_remark": remark,
"debit_in_account_currency": amount,
},
)
je.append(
"accounts", {"account": self.cash, "cost_center": self.main_cc, "credit_in_account_currency": 150}
)
je.save()
je.submit()
posted = {
(row.cost_center, row.remarks)
for row in frappe.get_all(
"Payment Ledger Entry",
filters={"voucher_no": je.name, "delinked": 0},
fields=["cost_center", "remarks"],
)
}
self.assertGreater(len(posted), 1, "fixture must post more than one ledger entry to be meaningful")
ledger = QueryPaymentLedger()
rows = ledger.get_voucher_outstandings(
vouchers=[frappe._dict(voucher_type="Journal Entry", voucher_no=je.name)]
)
self.assertTrue(rows)
for row in rows:
self.assertIn((row.cost_center, row.remarks), posted)
def test_voucher_outstanding_splits_by_party_account(self):
"""A voucher posting to two party accounts must report each account separately.
account is the join key between the amount and outstanding CTEs. Selecting Max(account)
while grouping without it made that key an aggregate over two different row sets, so the two
sides could pick different accounts, the join would miss and the outstanding come back NULL.
It also summed amounts across accounts that need not share a currency.
"""
from erpnext.accounts.utils import QueryPaymentLedger
second_receivable = "_Test Receivable - _TC"
je = frappe.new_doc("Journal Entry")
je.posting_date = nowdate()
je.company = self.company
je.user_remark = "two receivable accounts"
for account, amount in ((self.debit_to, 100), (second_receivable, 60)):
je.append(
"accounts",
{
"account": account,
"party_type": "Customer",
"party": self.customer,
"cost_center": self.main_cc,
"debit_in_account_currency": amount,
},
)
je.append(
"accounts", {"account": self.cash, "cost_center": self.main_cc, "credit_in_account_currency": 160}
)
je.save()
je.submit()
rows = QueryPaymentLedger().get_voucher_outstandings(
vouchers=[frappe._dict(voucher_type="Journal Entry", voucher_no=je.name)]
)
by_account = {row.account: row for row in rows}
self.assertEqual(set(by_account), {self.debit_to, second_receivable})
self.assertEqual(flt(by_account[self.debit_to].invoice_amount), 100)
self.assertEqual(flt(by_account[second_receivable].invoice_amount), 60)
for row in rows:
self.assertIsNotNone(row.outstanding)
def test_filter_min_max(self):
# check filter condition minimum and maximum amount
self.create_sales_invoice(qty=1, rate=300)
@@ -1004,85 +859,6 @@ class TestPaymentReconciliation(ERPNextTestSuite):
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
def test_exchange_gain_loss_split_default_account(self):
gain_account, loss_account = self.setup_split_exchange_accounts()
self.create_foreign_currency_sales_invoice(conversion_rate=80)
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=85)
pr = self.create_payment_reconciliation()
pr.party = self.customer_usd
pr.receivable_payable_account = self.debtors_usd
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(pr.allocation[0].difference_amount, 500)
self.assertEqual(pr.allocation[0].difference_account, gain_account)
pr.reconcile()
self.create_foreign_currency_sales_invoice(conversion_rate=85)
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80)
pr = self.create_payment_reconciliation()
pr.party = self.customer_usd
pr.receivable_payable_account = self.debtors_usd
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(pr.allocation[0].difference_amount, -500)
self.assertEqual(pr.allocation[0].difference_account, loss_account)
def test_payment_reconciliation_difference_account_override(self):
_, loss_account = self.setup_split_exchange_accounts()
override_account = create_account(
account_name="_Test PR Override Exchange Account",
parent_account="Indirect Expenses - _TC",
company=self.company,
)
si = self.create_foreign_currency_sales_invoice(conversion_rate=85)
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80)
pr = self.create_payment_reconciliation()
pr.party = self.customer_usd
pr.receivable_payable_account = self.debtors_usd
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Default, computed from the split company fields, is pre-filled onto the row...
self.assertEqual(pr.allocation[0].difference_amount, -500)
self.assertEqual(pr.allocation[0].difference_account, loss_account)
# ...but the user can override it in the "Select Difference Account" dialog before reconciling,
# and that explicit choice must be what actually gets booked, not the computed default.
pr.allocation[0].difference_account = override_account
pr.reconcile()
jea_parent = frappe.db.get_all(
"Journal Entry Account",
filters={"account": self.debtors_usd, "docstatus": 1, "reference_name": si.name, "credit": 500},
fields=["parent"],
)[0]
self.assertEqual(
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
gain_loss_line_account = frappe.db.get_value(
"Journal Entry Account",
{"parent": jea_parent.parent, "account": ["!=", self.debtors_usd]},
"account",
)
self.assertEqual(gain_loss_line_account, override_account)
def test_difference_amount_via_negative_debit_or_credit_journal_entry(self):
# Make Sale Invoice
si = self.create_sales_invoice(
@@ -2626,86 +2402,6 @@ class TestPaymentReconciliation(ERPNextTestSuite):
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
pr.reconcile()
def test_foreign_currency_reverse_payment_entry_gain_for_supplier(self):
transaction_date = nowdate()
self.supplier = "_Test Supplier USD"
amount = 100
department = frappe.db.get_value("Department", {"company": self.company, "is_group": 0}, "name")
# Pay USD 100 at an exchange rate of 90.
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
pe.payment_type = "Pay"
pe.party_type = "Supplier"
pe.party = self.supplier
pe.paid_from = self.cash
pe.paid_from_account_currency = "INR"
pe.target_exchange_rate = 90
pe.paid_amount = 90 * amount
pe.received_amount = amount
pe.paid_to = self.creditors_usd
pe.paid_to_account_currency = "USD"
pe.department = department
pe = pe.save().submit()
# Receive USD 100 from the supplier at an exchange rate of 100.
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
reverse_pe.payment_type = "Receive"
reverse_pe.party_type = "Supplier"
reverse_pe.party = self.supplier
reverse_pe.paid_from = self.creditors_usd
reverse_pe.paid_from_account_currency = "USD"
reverse_pe.source_exchange_rate = 100
reverse_pe.paid_amount = amount
reverse_pe.received_amount = 100 * amount
reverse_pe.paid_to = self.cash
reverse_pe.paid_to_account_currency = "INR"
reverse_pe.department = department
reverse_pe = reverse_pe.save().submit()
pr = self.create_payment_reconciliation(party_is_customer=False)
pr.party = self.supplier
pr.receivable_payable_account = self.creditors_usd
pr.get_unreconciled_entries()
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
for row in pr.allocation:
row.department = department
self.assertEqual(flt(pr.allocation[0].difference_amount), 1000)
pr.reconcile()
gain_loss_journal = frappe.db.get_value(
"Journal Entry Account",
{
"reference_type": reverse_pe.doctype,
"reference_name": reverse_pe.name,
"party": self.supplier,
"docstatus": 1,
},
"parent",
)
party_row = frappe.db.get_value(
"Journal Entry Account",
{"parent": gain_loss_journal, "party": self.supplier},
["debit", "credit"],
as_dict=True,
)
self.assertEqual(flt(party_row.debit), 1000)
self.assertEqual(flt(party_row.credit), 0)
party_gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_no": ["in", [pe.name, reverse_pe.name, gain_loss_journal]],
"account": self.creditors_usd,
"party": self.supplier,
"is_cancelled": 0,
},
fields=["debit", "credit"],
)
self.assertEqual(flt(sum(row.debit - row.credit for row in party_gl_entries)), 0)
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
transaction_date = nowdate()
customer = self.customer_usd

View File

@@ -6,10 +6,8 @@ import copy
import frappe
from frappe import _
from frappe.query_builder.functions import Max, Sum
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
from frappe.utils import add_days, flt, formatdate, getdate
from erpnext import is_perpetual_inventory_enabled
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
make_closing_entries,
)
@@ -19,8 +17,6 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import apply_unscoped_filters
from erpnext.stock.utils import get_stock_value_on
class PeriodClosingVoucher(AccountsController):
@@ -145,121 +141,6 @@ class PeriodClosingVoucher(AccountsController):
if account_currency != company_currency:
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
def before_submit(self):
if not self.has_stock_transactions():
return
self.validate_stock_accounts_balance()
self.validate_stock_closing_entry()
def has_stock_transactions(self):
if not is_perpetual_inventory_enabled(self.company):
return False
return bool(
frappe.db.exists(
"Stock Ledger Entry",
{
"company": self.company,
"is_cancelled": 0,
"posting_date": ("<=", self.period_end_date),
},
)
)
def validate_stock_accounts_balance(self):
precision = frappe.get_precision("GL Entry", "debit")
account_balance = flt(self.get_stock_accounts_balance(), precision)
stock_value = flt(
get_stock_value_on(posting_date=self.period_end_date, company=self.company), precision
)
if account_balance == stock_value:
return
currency = frappe.get_cached_value("Company", self.company, "default_currency")
frappe.throw(
_(
"The closing balance {0} of the Stock Asset accounts does not match the closing value {1} of the Stock Balance report as on {2}. Resolve the difference using the Stock Ledger Variance report before closing the period."
).format(
frappe.bold(fmt_money(account_balance, currency=currency)),
frappe.bold(fmt_money(stock_value, currency=currency)),
frappe.bold(formatdate(self.period_end_date)),
),
title=_("Stock Value Mismatch"),
)
def get_stock_accounts_balance(self):
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
stock_accounts = (
frappe.qb.from_(account)
.select(account.name)
.where(
(account.account_type == "Stock")
& (account.company == self.company)
& (account.is_group == 0)
)
)
balance = (
frappe.qb.from_(gle)
.select(Sum(gle.debit - gle.credit))
.where(
(gle.company == self.company)
& (gle.is_cancelled == 0)
& (gle.posting_date <= self.period_end_date)
& gle.account.isin(stock_accounts)
)
).run()
return flt(balance[0][0]) if balance else 0.0
def validate_stock_closing_entry(self):
closing_entry = frappe.db.get_value(
"Stock Closing Entry",
apply_unscoped_filters(
{"company": self.company, "to_date": self.period_end_date, "docstatus": 1}
),
["name", "status", "modified"],
as_dict=True,
)
if not closing_entry:
frappe.throw(
_(
"Create a Stock Closing Entry for the entire company with To Date as {0} before submitting the Period Closing Voucher."
).format(frappe.bold(formatdate(self.period_end_date))),
title=_("Stock Closing Entry Required"),
)
if closing_entry.status != "Completed":
frappe.throw(
_(
"The Stock Closing Entry for {0} is not completed yet. Wait for it to complete before submitting the Period Closing Voucher."
).format(frappe.bold(formatdate(self.period_end_date))),
title=_("Stock Closing Entry In Progress"),
)
self.validate_stock_closing_entry_is_fresh(closing_entry)
def validate_stock_closing_entry_is_fresh(self, closing_entry):
sle = frappe.qb.DocType("Stock Ledger Entry")
last_change = (
frappe.qb.from_(sle)
.select(Max(sle.modified))
.where((sle.company == self.company) & (sle.posting_date <= self.period_end_date))
).run()
if last_change and last_change[0][0] and last_change[0][0] > closing_entry.modified:
frappe.throw(
_(
"Stock transactions were created or modified after the Stock Closing Entry {0} was generated. Regenerate it before submitting the Period Closing Voucher."
).format(get_link_to_form("Stock Closing Entry", closing_entry.name)),
title=_("Stock Closing Entry Outdated"),
)
def on_submit(self):
self.db_set("gle_processing_status", "In Progress")
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):

View File

@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt, today
from frappe.utils import today
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
@@ -386,218 +386,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertEqual(acb_figures["Cash"][key_for(cc1)], 400)
self.assertEqual(acb_figures["Cash"][key_for(cc2)], 200)
def test_stock_validations_before_period_closing(self):
from unittest.mock import patch
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
create_custom_fields(
{
"Stock Closing Entry": [
{
"fieldname": "warehouse",
"label": "Warehouse",
"fieldtype": "Link",
"options": "Warehouse",
}
]
}
)
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
se = make_stock_entry(
item_code=item.name,
qty=10,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-03-15",
)
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
sce = frappe.get_doc(
{
"doctype": "Stock Closing Entry",
"company": "Test PCV Company",
"from_date": pcv.period_start_date,
"to_date": pcv.period_end_date,
"warehouse": "Stores - TPC",
}
).insert()
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
sce.submit()
sce.db_set("status", "Completed")
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
frappe.db.set_value("Stock Closing Entry", sce.name, {"warehouse": None, "status": "In Progress"})
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "is not completed yet", pcv.submit)
sce.create_stock_closing_balance_entries()
sce.db_set("status", "Completed")
sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": se.name},
["name", "stock_value_difference"],
as_dict=1,
)
frappe.db.set_value(
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + 100
)
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "does not match", pcv.submit)
frappe.db.set_value(
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference
)
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
self.rebuild_stock_closing_balance(sce)
pcv.reload()
pcv.submit()
self.assertEqual(pcv.docstatus, 1)
def test_batch_valuation_seeded_from_stock_closing_after_period_closing(self):
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_entry.stock_entry_utils import make_stock_entry
item = make_item(
"Test PCV Batch Item",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TPCVB.####",
},
)
se1 = make_stock_entry(
item_code=item.name,
qty=10,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-03-15",
)
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
make_stock_entry(
item_code=item.name,
qty=10,
rate=200,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-06-15",
batch_no=batch_no,
)
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
pcv.reload()
pcv.submit()
outward = make_stock_entry(
item_code=item.name,
qty=5,
from_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2022-04-01",
batch_no=batch_no,
)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": outward.name, "is_cancelled": 0},
"stock_value_difference",
)
self.assertEqual(flt(stock_value_difference, 2), -750.0)
self.assertRaisesRegex(
frappe.ValidationError,
"frozen",
make_stock_entry,
item_code=item.name,
qty=1,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-05-01",
)
self.assertRaisesRegex(frappe.ValidationError, "frozen", se1.cancel)
self.assertRaisesRegex(frappe.ValidationError, "closed accounting period", sce.cancel)
def test_period_closing_blocks_stale_stock_closing_entry(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
make_stock_entry(
item_code=item.name,
qty=10,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-03-15",
)
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
make_stock_entry(
item_code=item.name,
qty=5,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-05-01",
)
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
self.rebuild_stock_closing_balance(sce)
pcv.reload()
pcv.submit()
self.assertEqual(pcv.docstatus, 1)
def make_completed_stock_closing_entry(self, from_date, to_date):
from unittest.mock import patch
sce = frappe.get_doc(
{
"doctype": "Stock Closing Entry",
"company": "Test PCV Company",
"from_date": from_date,
"to_date": to_date,
}
).insert()
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
sce.submit()
sce.create_stock_closing_balance_entries()
sce.db_set("status", "Completed")
return sce
def rebuild_stock_closing_balance(self, sce):
sce.remove_stock_closing()
sce.create_stock_closing_balance_entries()
sce.db_set("status", "Completed")
def make_period_closing_voucher(self, posting_date, submit=True):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")

View File

@@ -259,7 +259,6 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -889,7 +888,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Item",

View File

@@ -240,8 +240,10 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
unblock_invoice() {
const me = this;
me.frm.call("unblock_invoice", null, () => {
me.frm.reload_doc();
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice",
args: { name: me.frm.doc.name },
callback: (r) => me.frm.reload_doc(),
});
}
@@ -292,16 +294,15 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
this.dialog.set_primary_action(__("Save"), function () {
const dialog_data = me.dialog.get_values();
me.frm.call(
"block_invoice",
{
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice",
args: {
name: me.frm.doc.name,
hold_comment: dialog_data.hold_comment,
release_date: dialog_data.release_date,
},
() => {
me.frm.reload_doc();
}
);
callback: (r) => me.frm.reload_doc(),
});
me.dialog.hide();
});
@@ -340,9 +341,10 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
set_release_date(data) {
const me = this;
return me.frm.call("change_release_date", { release_date: data.release_date }, () => {
me.frm.reload_doc();
return frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date",
args: data,
callback: (r) => this.frm.reload_doc(),
});
}

View File

@@ -360,7 +360,6 @@
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.on_hold",
"depends_on": "eval:doc.on_hold",
"fieldname": "sb_14",
"fieldtype": "Section Break",
"label": "Hold Invoice"
@@ -1695,7 +1694,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-08-05 15:40:16.519774",
"modified": "2026-07-12 23:54:21.263951",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _, throw
from frappe.model.document import Document
from frappe.utils import DateTimeLikeObject, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date
@@ -235,9 +235,6 @@ class PurchaseInvoice(BuyingController):
"overflow_type": "billing",
}
]
self.closed_source_links = [
("Purchase Invoice Item", "pr_detail", "Purchase Receipt Item", "Purchase Receipt")
]
def onload(self):
super().onload()
@@ -309,9 +306,6 @@ class PurchaseInvoice(BuyingController):
PurchaseTaxWithholding(self).on_validate()
self.set_percentage_received()
if self.on_hold:
self.validate_invoice_hold()
def set_percentage_received(self):
total_billed_qty = 0.0
total_received_qty = 0.0
@@ -323,13 +317,6 @@ class PurchaseInvoice(BuyingController):
if total_billed_qty and total_received_qty:
self.per_received = total_received_qty / total_billed_qty * 100
def validate_invoice_hold(self):
if self.is_return:
frappe.throw(_("Return Purchase Invoice cannot be held."))
if self.docstatus < 1:
frappe.throw(_("Purchase Invoice can be held after submitting."))
def validate_release_date(self):
if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
frappe.throw(_("Release date must be in the future"))
@@ -833,38 +820,14 @@ class PurchaseInvoice(BuyingController):
def on_recurring(self, reference_doc, auto_repeat_doc):
self.due_date = None
@frappe.whitelist(methods=["POST"])
def block_invoice(self, hold_comment: str | None = None, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
self.on_hold = 1
self.release_date = release_date
self.validate_block_invoice()
self.db_set({"on_hold": 1, "hold_comment": cstr(hold_comment), "release_date": release_date})
@frappe.whitelist(methods=["POST"])
def unblock_invoice(self):
self.check_permission("write")
self.db_set({"on_hold": 0, "release_date": None})
@frappe.whitelist(methods=["POST"])
def change_release_date(self, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
if not self.on_hold:
frappe.throw(_("Invoice is not blocked. Block the invoice to change the release date."))
self.release_date = release_date
self.validate_block_invoice()
def block_invoice(self, hold_comment=None, release_date=None):
self.db_set("on_hold", 1)
self.db_set("hold_comment", cstr(hold_comment))
self.db_set("release_date", release_date)
def validate_block_invoice(self):
self.validate_invoice_hold()
if self.outstanding_amount <= 0:
frappe.throw(_("Purchase Invoice without any outstanding amount cannot be held."))
self.validate_release_date()
def unblock_invoice(self):
self.db_set("on_hold", 0)
self.db_set("release_date", None)
def set_status(self, update=False, status=None, update_modified=True):
if self.is_new():
@@ -962,3 +925,24 @@ def get_list_context(context=None):
@erpnext.allow_regional
def make_regional_gl_entries(gl_entries, doc):
return gl_entries
@frappe.whitelist()
def change_release_date(name: str, release_date: str | None = None):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.check_permission()
pi.db_set("release_date", release_date)
@frappe.whitelist()
def unblock_invoice(name: str):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.unblock_invoice()
@frappe.whitelist()
def block_invoice(name: str, release_date: str, hold_comment: str | None = None):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.block_invoice(hold_comment, release_date)

View File

@@ -278,166 +278,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
def test_purchase_invoice_explicit_block(self):
pi = make_purchase_invoice()
release_date = add_days(nowdate(), 10)
pi.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
pi.block_invoice()
self.assertEqual(pi.on_hold, 1)
on_hold, hold_comment, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "hold_comment", "release_date"]
)
self.assertEqual(on_hold, 1)
self.assertEqual(hold_comment, "Waiting for the goods")
self.assertEqual(getdate(saved_release_date), getdate(release_date))
pi.unblock_invoice()
self.assertEqual(pi.on_hold, 0)
on_hold, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "release_date"]
)
self.assertEqual(on_hold, 0)
self.assertIsNone(saved_release_date)
def test_purchase_invoice_cannot_be_held_before_submission(self):
pi = make_purchase_invoice(do_not_save=True)
pi.on_hold = 1
self.assertRaises(frappe.ValidationError, pi.save)
pi.on_hold = 0
pi.save()
pi.submit()
pi.block_invoice()
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 1)
def test_return_purchase_invoice_cannot_be_held(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.on_hold = 1
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.save)
return_pi.on_hold = 0
return_pi.save()
return_pi.submit()
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.block_invoice)
def test_return_purchase_invoice_is_not_affected_by_hold_validations(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
# a return has a negative outstanding amount, which must not be mistaken
# for an invalid hold on a document that was never held
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.save()
return_pi.submit()
self.assertEqual(return_pi.docstatus, 1)
self.assertEqual(return_pi.on_hold, 0)
self.assertLess(return_pi.outstanding_amount, 0)
def test_settled_purchase_invoice_cannot_be_held(self):
pi = make_purchase_invoice()
pe = get_payment_entry("Purchase Invoice", dn=pi.name, bank_account="_Test Bank - _TC")
pe.reference_no = "1"
pe.reference_date = nowdate()
pe.save()
pe.submit()
pi.reload()
self.assertEqual(pi.outstanding_amount, 0)
self.assertRaises(frappe.ValidationError, pi.block_invoice)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_release_date_of_held_invoice_must_be_in_future(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", nowdate())
def test_rejected_hold_does_not_partially_update_invoice(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
pi.reload()
self.assertEqual(pi.on_hold, 0)
self.assertIsNone(pi.release_date)
def test_change_release_date_of_held_invoice(self):
pi = make_purchase_invoice()
pi.block_invoice(hold_comment="Hold", release_date=add_days(nowdate(), 10))
new_release_date = add_days(nowdate(), 20)
pi.change_release_date(new_release_date)
self.assertEqual(
getdate(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")),
getdate(new_release_date),
)
self.assertRaises(frappe.ValidationError, pi.change_release_date, add_days(nowdate(), -1))
def test_release_date_cannot_be_changed_on_an_invoice_that_is_not_held(self):
pi = make_purchase_invoice()
self.assertRaisesRegex(
frappe.ValidationError,
"Invoice is not blocked",
pi.change_release_date,
add_days(nowdate(), 10),
)
self.assertIsNone(frappe.db.get_value("Purchase Invoice", pi.name, "release_date"))
def test_hold_methods_are_whitelisted_document_methods(self):
import erpnext.accounts.doctype.purchase_invoice.purchase_invoice as purchase_invoice_module
pi = frappe.new_doc("Purchase Invoice")
for method in ("block_invoice", "unblock_invoice", "change_release_date"):
# raises if the method is not whitelisted for client side calls
pi.is_whitelisted(method)
self.assertFalse(
hasattr(purchase_invoice_module, method),
f"{method} should only be exposed as a document method",
)
def test_hold_methods_require_write_permission(self):
pi = make_purchase_invoice()
user = "test_pi_hold_permission@example.com"
if not frappe.db.exists("User", user):
frappe.get_doc(
{
"doctype": "User",
"email": user,
"first_name": "Test PI Hold",
"roles": [{"role": "Employee"}],
}
).insert(ignore_permissions=True)
frappe.set_user(user)
try:
self.assertRaises(frappe.PermissionError, pi.block_invoice)
self.assertRaises(frappe.PermissionError, pi.unblock_invoice)
self.assertRaises(frappe.PermissionError, pi.change_release_date, add_days(nowdate(), 10))
finally:
frappe.set_user("Administrator")
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_gl_entries_with_perpetual_inventory_against_pr(self):
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",

View File

@@ -241,7 +241,6 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -1033,7 +1032,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -587,12 +587,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
super.set_dynamic_labels();
this.frm.events.hide_fields(this.frm);
const hide_update_stock = cint(this.frm.doc.is_debit_note) || cint(this.frm.doc.has_subcontracted);
// frm.set_df_property mutates a per-document copy, not the doctype's shared field
// metadata, so this always reflects the original (Customize Form) hidden value.
const hidden_by_customization = cint(
frappe.meta.get_docfield("Sales Invoice", "update_stock")?.hidden
);
this.frm.set_df_property("update_stock", "hidden", hide_update_stock || hidden_by_customization);
this.frm.set_df_property("update_stock", "hidden", hide_update_stock);
}
items_on_form_rendered() {

View File

@@ -273,9 +273,6 @@ class SalesInvoice(SellingController):
"overflow_type": "billing",
}
]
self.closed_source_links = [
("Sales Invoice Item", "dn_detail", "Delivery Note Item", "Delivery Note")
]
def set_indicator(self):
"""Set indicator for portal"""
@@ -1168,7 +1165,6 @@ class SalesInvoice(SellingController):
child_tables = {
"items": ("income_account", "expense_account", "discount_account"),
"taxes": ("account_head",),
"payments": ("account",),
}
self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables)
if self.needs_repost:

View File

@@ -249,7 +249,6 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -1067,7 +1066,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -1,6 +1,5 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2016-05-08 23:49:38.842621",
"doctype": "DocType",
"editable_grid": 1,
@@ -18,7 +17,6 @@
],
"fields": [
{
"allow_on_submit": 1,
"fieldname": "mode_of_payment",
"fieldtype": "Link",
"in_list_view": 1,
@@ -41,7 +39,6 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "account",
"fieldtype": "Link",
"label": "Account",
@@ -50,7 +47,6 @@
"read_only": 1
},
{
"allow_on_submit": 1,
"fetch_from": "mode_of_payment.type",
"fieldname": "type",
"fieldtype": "Read Only",
@@ -89,7 +85,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-07-29 16:44:54.482826",
"modified": "2026-02-16 20:46:34.592604",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Payment",

View File

@@ -278,9 +278,6 @@ class Subscription(Document):
"""
Sets the status of the `Subscription`
"""
if self.status == STATUS_CANCELLED:
return
self._set_current_invoice_dates()
if self.is_trialling():
self.status = STATUS_TRIALING
@@ -676,7 +673,7 @@ class Subscription(Document):
if self.cancel_at_period_end and (
getdate(posting_date) >= getdate(self.next_billing_period_end)
or (self.end_date and getdate(posting_date) >= getdate(self.end_date))
or getdate(posting_date) >= getdate(self.end_date)
):
self.cancel_subscription()

View File

@@ -779,38 +779,6 @@ class TestSubscription(ERPNextTestSuite):
subscription.reload()
self.assertEqual(subscription.status, "Active")
def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self):
# https://github.com/frappe/erpnext/issues/57761
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
cancel_at_period_end=1,
)
subscription.process(posting_date=nowdate())
invoice = subscription.get_current_invoice()
self.assertGreater(invoice.outstanding_amount, 0)
subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled")
cancelation_date = getdate(subscription.cancelation_date)
self.assertIsNotNone(cancelation_date)
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
payment_entry.reference_no = "12345"
payment_entry.reference_date = nowdate()
payment_entry.submit()
subscription.reload()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
invoice_count = len(subscription.invoices)
subscription.process()
subscription.reload()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoice_count)
def test_first_invoice_generated_on_create_for_prepaid(self):
subscription = create_subscription(
start_date=nowdate(),

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-09 16:13:49.623613",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Party Account - Accounts",
"name": "Party Account (Standard)",
"owner": "Administrator"
}

View File

@@ -27,6 +27,6 @@
"modified": "2026-07-10 11:26:57.841200",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry - Accounts",
"name": "Payment Entry (Standard)",
"owner": "Administrator"
}

View File

@@ -71,6 +71,6 @@
"modified": "2026-07-20 15:56:46.025286",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice - Accounts",
"name": "Purchase Invoice (Standard)",
"owner": "Administrator"
}

View File

@@ -63,6 +63,6 @@
"modified": "2026-07-20 15:32:43.080034",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice - Accounts",
"name": "Sales Invoice (Standard)",
"owner": "Administrator"
}

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-09 15:08:57.487184",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription - Accounts",
"name": "Subscription (Standard)",
"owner": "Administrator"
}

View File

@@ -865,12 +865,10 @@ def validate_account_party_type(self):
def get_dashboard_info(party_type, party, loyalty_program=None):
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
if not frappe.has_permission(doctype, "read"):
return None
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
)

View File

@@ -24,11 +24,6 @@ class TestAccountBalance(ERPNextTestSuite):
"currency": "EUR",
"balance": -100.0,
},
{
"account": "Exchange Gain - _TC2",
"currency": "EUR",
"balance": 0.0,
},
{
"account": "Income - _TC2",
"currency": "EUR",

View File

@@ -553,8 +553,8 @@ def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, inc
else:
invoice_expense_map[d.parent][d.account_head] = flt(d.tax_amount)
else:
invoice_tax_map.setdefault(d.parent, frappe._dict()).setdefault(d.account_head, 0.0)
invoice_tax_map[d.parent][d.account_head] += flt(d.tax_amount)
invoice_tax_map.setdefault(d.parent, frappe._dict()).setdefault(d.account_head, [])
invoice_tax_map[d.parent][d.account_head] = flt(d.tax_amount)
return invoice_expense_map, invoice_tax_map

View File

@@ -47,41 +47,6 @@ class TestPurchaseRegister(ERPNextTestSuite):
self.assertEqual(labels, sorted([lower, upper], key=str.casefold))
def test_add_and_deduct_rows_on_one_account_are_netted(self):
"""An account head carrying both an Add and a Deduct row must report their net.
The tax query groups by (parent, account_head, add_deduct_tax), so such an account comes
back as two rows. Only one of them survived into the report.
"""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
make_purchase_invoice as make_pi,
)
company = "_Test Company"
tax_account = "_Test Account VAT - _TC"
pi = make_pi(company=company, do_not_save=True)
for add_deduct, amount in (("Add", 10), ("Deduct", 4)):
pi.append(
"taxes",
{
"charge_type": "Actual",
"account_head": tax_account,
"description": "VAT",
"category": "Total",
"add_deduct_tax": add_deduct,
"tax_amount": amount,
"cost_center": "Main - _TC",
},
)
pi.save()
pi.submit()
filters = frappe._dict(company=company, from_date=add_months(today(), -1), to_date=today())
row = next(r for r in execute(filters)[1] if r.get("voucher_no") == pi.name)
self.assertEqual(flt(row.get(frappe.scrub(tax_account))), 6.0)
def test_purchase_register_ignores_tax_rows_from_other_doctype(self):
filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today())

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce, Max, Min, Sum
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.utils import cstr
@@ -128,47 +128,25 @@ def get_pos_invoice_data(filters):
sip = frappe.qb.DocType("Sales Invoice Payment")
si = frappe.qb.DocType("Sales Invoice")
# t1: one row per invoice with the summed item base_total. warehouse and cost_center describe an
# item line, not the invoice, and an invoice may carry several. warehouse then becomes an outer
# grouping key below, so which line wins decides how rows are partitioned and what each row totals
# -- not merely which label is shown. Max() over text is a sort, and MariaDB (case-folding) and
# PostgreSQL (byte order) resolve it differently, so take both off one real line instead.
# The representative is the first line the user entered: Min(idx) is an integer, so the pick is
# free of collation and is meaningful, rather than turning on an unrelated hash-named row.
grouped_items = (
frappe.qb.from_(sii)
.select(sii.parent, Sum(sii.amount).as_("base_total"), Min(sii.idx).as_("representative_idx"))
.groupby(sii.parent)
).as_("grouped_items")
representative_item = frappe.qb.DocType("Sales Invoice Item").as_("representative_item")
# t1: one row per invoice with the summed item base_total. warehouse/cost_center are line-level and
# not grouped, so they are arbitrary per invoice -- Max() makes that pick deterministic and valid on
# Postgres (item_code was selected but never consumed downstream, so it is dropped).
t1 = (
frappe.qb.from_(grouped_items)
.inner_join(representative_item)
.on(
(representative_item.parent == grouped_items.parent)
& (representative_item.idx == grouped_items.representative_idx)
)
frappe.qb.from_(sii)
.select(
grouped_items.parent,
grouped_items.base_total,
representative_item.warehouse,
representative_item.cost_center,
sii.parent,
Sum(sii.amount).as_("base_total"),
Max(sii.warehouse).as_("warehouse"),
Max(sii.cost_center).as_("cost_center"),
)
.groupby(sii.parent)
)
# t3: mode_of_payment per invoice, from one real payment line for the same reason
grouped_payments = (
frappe.qb.from_(sip).select(sip.parent, Min(sip.idx).as_("representative_idx")).groupby(sip.parent)
).as_("grouped_payments")
representative_payment = frappe.qb.DocType("Sales Invoice Payment").as_("representative_payment")
# t3: mode_of_payment per invoice (arbitrary across an invoice's payment lines -> Max() to be valid)
t3 = (
frappe.qb.from_(grouped_payments)
.inner_join(representative_payment)
.on(
(representative_payment.parent == grouped_payments.parent)
& (representative_payment.idx == grouped_payments.representative_idx)
)
.select(grouped_payments.parent, representative_payment.mode_of_payment.as_("mode_of_payment"))
frappe.qb.from_(sip)
.select(sip.parent, Max(sip.mode_of_payment).as_("mode_of_payment"))
.groupby(sip.parent)
)
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns

View File

@@ -54,53 +54,6 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
self.assertIn("Credit Card", next(iter(mop.values())))
self.assertNotIn("Cash", next(iter(mop.values())))
def test_pos_invoice_warehouse_and_cost_center_come_from_one_item(self):
"""The reported warehouse and cost centre must belong to the same item line.
They describe a line, not the invoice, and an invoice can carry several. Aggregating each
on its own can report a warehouse from one line beside a cost centre from another -- a pair
that was never posted. The warehouse is also an outer grouping key, so the pick decides how
rows are partitioned and what each one totals, not just what is displayed.
"""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
low_warehouse = create_warehouse("_Test POS Summary AAA")
high_warehouse = create_warehouse("_Test POS Summary ZZZ")
second_item = make_item("_Test POS Summary Second Item", {"is_stock_item": 0}).name
si = create_sales_invoice_record()
si.is_pos = 1
# cross the two picks: the higher warehouse is on the line with the lower cost centre, so an
# independently aggregated pair cannot belong to either line
si.items[0].warehouse = high_warehouse
si.items[0].cost_center = "Main - _TC"
si.append(
"items",
{
"item_code": second_item,
"qty": 1,
"rate": 5000,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"warehouse": low_warehouse,
"cost_center": "Sub - _TC",
},
)
si.append("payments", {"mode_of_payment": "Cash", "account": "_Test Cash - _TC", "amount": 15000})
si.insert()
si.submit()
posted = {(row.warehouse, row.cost_center) for row in si.items}
self.assertGreater(len(posted), 1, "fixture must post more than one distinct pair")
rows = get_pos_invoice_data(get_filters())
reported = [r for r in rows if r.get("warehouse") in {w for w, _ in posted}]
self.assertTrue(reported)
for row in reported:
self.assertIn((row["warehouse"], row["cost_center"]), posted)
def test_get_mode_of_payments_details(self):
filters = get_filters()

View File

@@ -11,13 +11,6 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
from erpnext.accounts.utils import create_gain_loss_journal, get_currency_precision
def get_exchange_gain_loss_account(company: str, is_gain: bool) -> str | None:
fieldname = "exchange_gain_account" if is_gain else "exchange_loss_account"
return frappe.get_cached_value("Company", company, fieldname) or frappe.get_cached_value(
"Company", company, "exchange_gain_loss_account"
)
def gain_loss_journal_already_booked(
gain_loss_account: str,
exc_gain_loss: float,
@@ -170,7 +163,9 @@ def make_exchange_gain_loss_journal(
reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
gain_loss_account = get_exchange_gain_loss_account(doc.company, reverse_dr_or_cr == "credit")
gain_loss_account = frappe.get_cached_value(
"Company", doc.company, "exchange_gain_loss_account"
)
je = create_gain_loss_journal(
doc.company,
args.get("difference_posting_date") if args else doc.posting_date,
@@ -200,7 +195,7 @@ def make_exchange_gain_loss_journal(
def is_payable_account(reference_doctype: str, account: str) -> bool:
if reference_doctype == "Purchase Invoice" or (
reference_doctype in ("Journal Entry", "Payment Entry")
reference_doctype == "Journal Entry"
and frappe.get_cached_value("Account", account, "account_type") == "Payable"
):
return True

View File

@@ -2379,16 +2379,13 @@ class QueryPaymentLedger:
)
# build query for voucher amount
# account is grouped, not aggregated: it is a join key against the outstanding CTE below, and
# it fixes the currency the amounts are summed in. The two CTEs aggregate over different row
# sets, so two Max() picks could disagree and the join would silently miss, leaving the
# outstanding NULL. posting_date/due_date are dates, so Max() there cannot depend on
# collation. cost_center and remarks are free text that genuinely varies per row, so they
# come off one real row instead -- see representative below.
grouped_voucher_amount = (
query_voucher_amount = (
qb.from_(ple)
.select(
ple.account,
# columns that are constant per (voucher_type, voucher_no, party_type, party) are
# wrapped in Max() so the query is valid on postgres (which, unlike MariaDB, requires
# every non-aggregated column to be grouped or aggregated)
Max(ple.account).as_("account"),
ple.voucher_type,
ple.voucher_no,
ple.party_type,
@@ -2396,47 +2393,25 @@ class QueryPaymentLedger:
Max(ple.posting_date).as_("posting_date"),
Max(ple.due_date).as_("due_date"),
Max(ple.account_currency).as_("currency"),
Max(ple.cost_center).as_("cost_center"),
Sum(ple.amount).as_("amount"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
Min(ple.name).as_("representative"),
Max(ple.remarks).as_("remarks"),
)
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_voucher_no))
.where(Criterion.all(self.common_filter))
.where(Criterion.all(self.dimensions_filter))
.where(Criterion.all(self.voucher_posting_date))
.groupby(ple.account, ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
).as_("grouped")
# Payment Ledger Entry has no autoname rule, so frappe names it by hash -- lower-case, which
# keeps Min(name) free of the collation divergence that picking Max() over free text has.
representative_ple = qb.DocType("Payment Ledger Entry").as_("representative_ple")
query_voucher_amount = (
qb.from_(grouped_voucher_amount)
.inner_join(representative_ple)
.on(representative_ple.name == grouped_voucher_amount.representative)
.select(
grouped_voucher_amount.account,
grouped_voucher_amount.voucher_type,
grouped_voucher_amount.voucher_no,
grouped_voucher_amount.party_type,
grouped_voucher_amount.party,
grouped_voucher_amount.posting_date,
grouped_voucher_amount.due_date,
grouped_voucher_amount.currency,
grouped_voucher_amount.amount,
grouped_voucher_amount.amount_in_account_currency,
representative_ple.cost_center.as_("cost_center"),
representative_ple.remarks.as_("remarks"),
)
.groupby(ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
)
# build query for voucher outstanding
query_voucher_outstanding = (
qb.from_(ple)
.select(
# grouped, not aggregated: this is the other side of the join key -- see above
ple.account,
# Max() on columns constant per group keeps this valid on postgres (see above)
Max(ple.account).as_("account"),
ple.against_voucher_type.as_("voucher_type"),
ple.against_voucher_no.as_("voucher_no"),
ple.party_type,
@@ -2450,7 +2425,7 @@ class QueryPaymentLedger:
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_against_voucher_no))
.where(Criterion.all(self.common_filter))
.groupby(ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
)
# build CTE for combining voucher amount and outstanding

View File

@@ -89,7 +89,6 @@ def make_purchase_receipt(
else abs(doc.received_qty) < abs(get_max_receivable_qty(doc))
)
and doc.delivered_by_supplier != 1
and not doc.closed
and select_item(doc),
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
@@ -194,7 +193,6 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
or abs(doc.billed_amt) < abs(doc.amount)
or doc.qty > flt(get_billed_qty(doc.name))
)
and not doc.closed
and select_item(doc),
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},

View File

@@ -14,9 +14,7 @@ frappe.ui.form.on("Purchase Order", {
setup: function (frm) {
frm.set_indicator_formatter("item_code", function (doc) {
let color;
if (doc.closed) {
color = "gray";
} else if (!doc.qty && frm.doc.has_unit_price_items) {
if (!doc.qty && frm.doc.has_unit_price_items) {
color = "yellow";
} else if (doc.qty <= doc.received_qty) {
color = "green";
@@ -342,7 +340,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
this.frm.page.set_inner_btn_group_as_primary(__("Status"));
}
} else if (["Closed", "Delivered"].includes(doc.status)) {
if (this.frm.has_perm("submit") && !doc.items.every((item) => item.closed)) {
if (this.frm.has_perm("submit")) {
this.frm.add_custom_button(
__("Re-open"),
() => this.unclose_purchase_order(),
@@ -354,7 +352,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
if (doc.status != "On Hold") {
if (
(doc.items
.filter((item) => !item.delivered_by_supplier && !item.closed)
.filter((item) => !item.delivered_by_supplier)
.some((item) => item.received_qty < item.qty) ||
doc.__onload?.has_pending_receivable_qty) &&
allow_receipt
@@ -367,11 +365,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
__("Create")
);
if (doc.is_subcontracted) {
if (
!doc.items
.filter((item) => !item.closed)
.every((item) => item.qty == item.subcontracted_qty)
) {
if (!doc.items.every((item) => item.qty == item.subcontracted_qty)) {
this.frm.add_custom_button(
__("Subcontracting Order"),
() => {
@@ -439,8 +433,6 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
} else if (doc.docstatus === 0) {
this.frm.cscript.add_from_mappers();
}
this.set_item_close_buttons();
}
validate() {
@@ -705,19 +697,6 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
this.frm.cscript.update_status("Close", "Closed");
}
set_item_close_buttons() {
erpnext.item_close.add_buttons(
this.frm,
erpnext.item_close.fulfilment_config({
qty_field: "received_qty",
qty_label: __("Received Qty"),
help: __(
"Closed rows stop being expected. Their pending quantity is written off and they are skipped when creating a Purchase Receipt or Purchase Invoice."
),
})
);
}
update_dropship_delivered_qty() {
const data = this.frm.doc.items
.filter((item) => item.delivered_by_supplier == 1)

View File

@@ -310,45 +310,12 @@ class PurchaseOrder(BuyingController):
itemwise_qty.setdefault(d.item_code, 0)
itemwise_qty[d.item_code] += flt(d.stock_qty)
precision = self.items[0].precision("stock_qty")
for item_code, qty in itemwise_qty.items():
if flt(qty, precision) < flt(itemwise_min_order_qty.get(item_code), precision):
if flt(qty) < flt(itemwise_min_order_qty.get(item_code)):
frappe.throw(
_(
"Item {0}: Ordered qty {1} cannot be less than minimum order qty {2} (defined in Item)."
).format(item_code, flt(qty, precision), itemwise_min_order_qty.get(item_code))
)
self.warn_marginal_min_order_qty(itemwise_qty, itemwise_min_order_qty)
def warn_marginal_min_order_qty(self, itemwise_qty, itemwise_min_order_qty):
"""Toast when an item's ordered qty exceeds its minimum only by purchase UOM rounding."""
if not self.is_new():
return
precision = self.items[0].precision("stock_qty")
itemwise_step = frappe._dict()
itemwise_stock_uom = frappe._dict()
for d in self.get("items"):
step = 10 ** -d.precision("qty") * flt(d.conversion_factor)
itemwise_step[d.item_code] = max(itemwise_step.get(d.item_code, 0), step)
itemwise_stock_uom[d.item_code] = d.stock_uom
for item_code, qty in itemwise_qty.items():
min_order_qty = flt(itemwise_min_order_qty.get(item_code))
overage = flt(qty) - min_order_qty
if min_order_qty and flt(overage, precision) > 0 and overage < itemwise_step[item_code]:
frappe.toast(
_(
"Item {0}: Ordered qty {1} {2} exceeds the minimum order qty {3} {2} by {4} {2} due to purchase UOM rounding."
).format(
item_code,
flt(qty, precision),
itemwise_stock_uom[item_code],
min_order_qty,
flt(overage, precision),
),
indicator="orange",
).format(item_code, qty, itemwise_min_order_qty.get(item_code))
)
def get_schedule_dates(self):
@@ -402,12 +369,6 @@ class PurchaseOrder(BuyingController):
def update_status(self, status):
StatusService(self).update_status(status)
def on_item_close_status_change(self):
StatusService(self).recalculate_after_item_close()
def is_item_closable(self, item):
return flt(item.received_qty) < flt(item.qty) or super().is_item_closable(item)
def on_submit(self):
super().on_submit()
@@ -537,7 +498,7 @@ class PurchaseOrder(BuyingController):
considering the configured over_delivery_receipt_allowance.
"""
for item in self.get("items", []):
if item.delivered_by_supplier or item.closed:
if item.delivered_by_supplier:
continue
tolerance = flt(get_allowance_for(item.item_code, qty_or_amount="qty")[0])
max_receivable_qty = flt(item.qty) * (100 + tolerance) / 100

View File

@@ -9,7 +9,6 @@ from frappe.desk.notifications import clear_doctype_notifications
from frappe.utils import cstr, flt
from erpnext.buying.doctype.purchase_order.services.subcontracting import SubcontractingService
from erpnext.controllers.item_close import validate_parent_reopen
class StatusService:
@@ -19,10 +18,6 @@ class StatusService:
def update_status(self, status: str) -> None:
doc = self.doc
self.check_modified_date()
if status != "Closed" and doc.status == "Closed":
validate_parent_reopen(doc)
doc.set_status(update=True, status=status)
doc.update_requested_qty()
doc.update_ordered_qty()
@@ -31,17 +26,6 @@ class StatusService:
doc.notify_update()
clear_doctype_notifications(doc)
def recalculate_after_item_close(self) -> None:
"""Refresh progress after row flags changed.
`update_billing_percentage` runs last because it reloads the parent and
writes the final status from both percentages.
"""
doc = self.doc
self.update_receiving_percentage()
doc.update_ordered_qty()
doc.update_billing_percentage()
def check_modified_date(self) -> None:
doc = self.doc
modified_in_db = frappe.db.get_value("Purchase Order", doc.name, "modified")
@@ -55,9 +39,10 @@ class StatusService:
def update_receiving_percentage(self) -> None:
doc = self.doc
total_qty, received_qty = 0.0, 0.0
for item in [item for item in doc.items if not item.closed] or doc.items:
for item in doc.items:
received_qty += min(item.received_qty, item.qty)
total_qty += item.qty
per_received = flt(received_qty / total_qty) * 100 if total_qty else 0
doc.db_set("per_received", per_received, update_modified=False)
if total_qty and received_qty:
doc.db_set("per_received", flt(received_qty / total_qty) * 100, update_modified=False)
else:
doc.db_set("per_received", 0, update_modified=False)

View File

@@ -216,21 +216,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
po2.items[0].qty = 110
self.assertRaises(OverAllowanceError, po2.submit)
# Stock over-delivery role must not bypass over-ordering against Material Request.
with self.change_settings(
"Stock Settings", {"role_allowed_to_over_deliver_receive": "Stock Manager"}
):
test_user = frappe.get_doc("User", "test@example.com")
test_user.add_roles("Stock Manager")
mr3 = make_material_request(qty=100)
po3 = make_purchase_order(mr3.name)
po3.supplier = "_Test Supplier"
po3.items[0].qty = 110
with self.set_user("test@example.com"):
po3.flags.ignore_permissions = True
self.assertRaises(OverAllowanceError, po3.submit)
# cleanup
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
@@ -707,66 +692,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
po = create_purchase_order(qty=3.4, do_not_save=True)
self.assertRaises(UOMMustBeIntegerError, po.insert)
def test_min_order_qty_with_uom_conversion_dust(self):
item_doc = make_item(properties={"min_order_qty": 2000, "stock_uom": "Kg"})
item_doc.append("uoms", {"uom": "Litre", "conversion_factor": 0.6})
item_doc.save()
item = item_doc.name
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
po.items[0].uom = "Litre"
po.items[0].conversion_factor = 0.6
po.insert()
below_minimum = create_purchase_order(item_code=item, qty=3000, do_not_save=1)
below_minimum.items[0].uom = "Litre"
below_minimum.items[0].conversion_factor = 0.6
self.assertRaises(frappe.ValidationError, below_minimum.insert)
def test_marginal_min_order_qty_overage_toast(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
if not frappe.db.exists("UOM", "Gram"):
frappe.get_doc({"doctype": "UOM", "uom_name": "Gram"}).insert()
item_doc = make_item(properties={"min_order_qty": 50000, "stock_uom": "Gram"})
item_doc.append("uoms", {"uom": "Pound", "conversion_factor": 453.592292197})
item_doc.save()
item = item_doc.name
def insert_po(qty):
po = create_purchase_order(item_code=item, qty=qty, do_not_save=1)
po.items[0].uom = "Pound"
po.items[0].conversion_factor = 453.592292197
frappe.clear_messages()
po.insert()
return any("minimum order qty" in d.get("message", "") for d in frappe.get_message_log())
self.assertTrue(insert_po(110.232))
self.assertFalse(insert_po(150))
def test_uom_integer_check_tolerates_conversion_dust(self):
from erpnext.utilities.transaction_base import UOMMustBeIntegerError
item_doc = make_item(properties={"stock_uom": "Nos"})
item_doc.append("uoms", {"uom": "Kg", "conversion_factor": 0.6})
item_doc.save()
item = item_doc.name
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
po.items[0].uom = "Kg"
po.items[0].conversion_factor = 0.6
po.insert()
fractional = create_purchase_order(item_code=item, qty=3333.9, do_not_save=1)
fractional.items[0].uom = "Kg"
fractional.items[0].conversion_factor = 0.6
self.assertRaises(UOMMustBeIntegerError, fractional.insert)
def test_ordered_qty_for_closing_po(self):
bin = frappe.get_all(
"Bin",
@@ -1119,8 +1044,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
compare_payment_schedules(self, po, pi)
@ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1})
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 1})
def test_internal_transfer_flow(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.sales_invoice.mapper import (
@@ -1132,6 +1055,9 @@ class TestPurchaseOrder(ERPNextTestSuite):
)
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1)
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
prepare_data_for_internal_transfer()
supplier = "_Test Internal Supplier 2"
@@ -1570,7 +1496,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(pi_2.status, "Paid")
self.assertEqual(po.status, "Completed")
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 0})
def test_purchase_order_over_billing_missing_item(self):
item1 = make_item(
"_Test Item for Overbilling",

View File

@@ -86,7 +86,6 @@
"returned_qty",
"column_break_60",
"billed_amt",
"closed",
"accounting_details",
"expense_account",
"column_break_fyqr",
@@ -261,7 +260,6 @@
"label": "UOM Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"print_hide": 1,
"print_width": "100px",
"reqd": 1,
@@ -646,15 +644,6 @@
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "closed",
"fieldtype": "Check",
"label": "Closed",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"description": "Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges",
"fieldname": "item_tax_rate",
@@ -954,7 +943,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 18:00:00.000000",
"modified": "2026-07-15 10:30:04.600510",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -29,7 +29,6 @@ class PurchaseOrderItem(Document):
blanket_order_rate: DF.Currency
bom: DF.Link | None
brand: DF.Link | None
closed: DF.Check
company_total_stock: DF.Float
conversion_factor: DF.Float
cost_center: DF.Link | None

View File

@@ -132,7 +132,6 @@
"label": "Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"read_only": 1
},
{
@@ -208,7 +207,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2024-03-27 13:10:26.235916",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Receipt Item Supplied",

View File

@@ -241,7 +241,6 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -275,7 +274,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-06-15 00:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Item",

View File

@@ -217,7 +217,6 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -615,7 +614,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-07-15 10:33:24.855979",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",

View File

@@ -47,6 +47,6 @@
"modified": "2026-07-20 15:54:26.047600",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order - Buying",
"name": "Purchase Order (Standard)",
"owner": "Administrator"
}

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-03 17:18:03.006829",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation - Buying",
"name": "Request for Quotation (Standard)",
"owner": "Administrator"
}

View File

@@ -15,6 +15,6 @@
"modified": "2026-07-03 17:14:32.891939",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation - Buying",
"name": "Supplier Quotation (Standard)",
"owner": "Administrator"
}

View File

@@ -51,6 +51,7 @@ def get_data(filters):
mr_item.item_code.as_("item_code"),
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
Max(Coalesce(mr_item.uom, "")).as_("uom"),
Max(Coalesce(mr_item.stock_uom, "")).as_("stock_uom"),
Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"),
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
@@ -59,6 +60,8 @@ def get_data(filters):
),
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.ordered_qty, 0))).as_("qty_to_order"),
Max(mr_item.item_name).as_("item_name"),
Max(mr_item.description).as_("description"),
Max(mr.company).as_("company"),
)
.where(
@@ -72,34 +75,8 @@ def get_data(filters):
query = get_conditions(filters, query, mr, mr_item) # add conditional conditions
query = query.groupby(mr.name, mr_item.item_code).orderby(Max(mr.transaction_date), Max(mr.schedule_date))
rows = query.run(as_dict=True)
apply_representative_lines(rows)
return rows
def apply_representative_lines(rows):
"""Fill item_name/description/uom from one real Material Request Item line per group.
All three are editable per line, so a request listing the same item twice holds several values
per group. Aggregating them sorts text, and MariaDB folds case while PostgreSQL orders by byte
value, so the engines pick differently. Take the first line by idx.
"""
material_requests = list({row.material_request for row in rows})
representative = {}
if material_requests:
for line in frappe.get_all(
"Material Request Item",
filters={"parent": ("in", material_requests), "docstatus": 1},
fields=["parent", "item_code", "item_name", "description", "uom"],
order_by="idx",
):
representative.setdefault((line.parent, line.item_code), line)
for row in rows:
line = representative.get((row.material_request, row.item_code))
row.item_name = line.item_name if line else None
row.description = line.description if line else None
row.uom = line.uom if line else ""
data = query.run(as_dict=True)
return data
def get_conditions(filters, query, mr, mr_item):

View File

@@ -39,7 +39,6 @@ from erpnext.accounts.utils import (
get_advance_payment_doctypes as _get_advance_payment_doctypes,
)
from erpnext.accounts.utils import get_fiscal_year, validate_fiscal_year
from erpnext.controllers.item_close import clear_closed_rows_on_amend
from erpnext.controllers.print_settings import (
set_print_templates_for_item_table,
set_print_templates_for_taxes,
@@ -211,23 +210,7 @@ class AccountsController(TransactionBase):
)
frappe.msgprint(msg)
def is_item_closable(self, item):
"""A row can be closed while anything is still pending on it.
Billing is the axis every closable document shares; the order doctypes
extend this with their own fulfilment axis.
Amounts are compared as magnitudes so that return rows stay closable.
That is deliberate: writing off a credit note that will never be issued
is a real decision, and closing a whole return document is already
allowed. Leaving it to the sign of the amount would decide it by
accident.
"""
return abs(flt(item.billed_amt)) < abs(flt(item.amount))
def validate(self):
clear_closed_rows_on_amend(self)
if not self.get("is_return") and not self.get("is_debit_note"):
self.validate_qty_is_not_zero()
@@ -1056,16 +1039,9 @@ class AccountsController(TransactionBase):
party_account = self.credit_to
dr_or_cr = "debit_in_account_currency"
from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account
lst = []
for d in self.get("advances"):
if flt(d.allocated_amount) > 0:
is_gain = (
flt(d.get("exchange_gain_loss")) > 0
if party_type == "Customer"
else flt(d.get("exchange_gain_loss")) < 0
)
args = frappe._dict(
{
"voucher_type": d.reference_type,
@@ -1092,7 +1068,9 @@ class AccountsController(TransactionBase):
else self.grand_total
),
"outstanding_amount": self.outstanding_amount,
"difference_account": get_exchange_gain_loss_account(self.company, is_gain),
"difference_account": frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
),
"exchange_gain_loss": flt(d.get("exchange_gain_loss")),
"difference_posting_date": d.get("difference_posting_date"),
}

View File

@@ -1,145 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Row level close and reopen for transaction items.
`REOPEN_STATUS` holds, per closable parent, the status its own Re-open button
passes to `update_status`. `set_status` recomputes from `status_map` anyway, so
the value is mostly a sentinel for "clear the Closed override" -- but not
always: Sales Order re-checks the credit limit only on the literal "Draft".
Reusing each doctype's own value keeps reopening a row indistinguishable from
reopening the document by hand.
"""
import frappe
from frappe import _
from frappe.utils import cint
REOPEN_STATUS = {
"Purchase Order": "Submitted",
"Sales Order": "Draft",
"Delivery Note": "Submitted",
"Purchase Receipt": "Submitted",
}
SETTLED_BY_CLOSE = ("per_ordered", "per_received", "per_delivered", "per_billed")
def has_closable_items(doctype: str | None) -> bool:
return doctype in REOPEN_STATUS
def closed_rows_settle(parent_doctype: str, item_doctype: str, percentage_field: str) -> bool:
"""Whether closed rows count as fully settled for this progress field.
Returns are excluded: closing a row writes off what is still pending on it,
it does not turn the row into a return.
"""
return (
percentage_field in SETTLED_BY_CLOSE
and has_closable_items(parent_doctype)
and frappe.get_meta(item_doctype).has_field("closed")
)
@frappe.whitelist()
def update_closed_status(doctype: str, name: str, item_names: str | list[str], closed: int) -> None:
if not has_closable_items(doctype):
frappe.throw(_("Rows of {0} cannot be closed individually").format(_(doctype)))
closed = 1 if cint(closed) else 0
item_names = set(frappe.parse_json(item_names) or [])
if not item_names:
frappe.throw(_("Select at least one row"))
doc = frappe.get_lazy_doc(doctype, name, check_permission="submit")
if doc.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(_(doctype), name))
changed = [row for row in doc.items if row.name in item_names and cint(row.closed) != closed]
if not changed:
return
if closed:
settled = [row for row in changed if not doc.is_item_closable(row)]
if settled:
frappe.throw(
_("Row #{0}: {1} is already completed in full, so there is nothing to close").format(
settled[0].idx, frappe.bold(settled[0].item_code)
)
)
validate_rows = getattr(doc, "validate_item_close", None)
if validate_rows:
validate_rows(changed)
for row in changed:
row.db_set("closed", closed)
doc.on_item_close_status_change()
doc.reload()
if closed:
close_parent_if_fully_closed(doc)
else:
reopen_parent_if_closed(doc)
doc.notify_update()
def close_parent_if_fully_closed(doc) -> None:
"""Close the parent once every row has been closed."""
if doc.status == "Closed":
return
if all(cint(row.closed) for row in doc.items):
doc.update_status("Closed")
def reopen_parent_if_closed(doc) -> None:
"""Reopen the parent so the row that was just reopened can be acted on.
A closed parent suppresses its rows everywhere, so leaving it closed would
make reopening a row look like it did nothing.
"""
if doc.status == "Closed":
doc.update_status(REOPEN_STATUS[doc.doctype])
def is_bundle_of_closed_row(packed_item) -> bool:
"""A packed item follows the row of its parent document that bundles it."""
if not packed_item.parent_detail_docname or not packed_item.parenttype:
return False
item_doctype = f"{packed_item.parenttype} Item"
return bool(frappe.db.get_value(item_doctype, packed_item.parent_detail_docname, "closed"))
def clear_closed_rows_on_amend(doc) -> None:
"""An amended document starts with nothing written off.
Frappe copies `no_copy` fields when amending so a cancelled document can be
corrected and resubmitted, which would otherwise carry a write-off decision
that was made against the cancelled document onto the new one.
"""
if not doc.is_new() or not doc.get("amended_from") or not has_closable_items(doc.doctype):
return
for row in doc.get("items") or []:
row.closed = 0
def validate_parent_reopen(doc) -> None:
"""Block reopening a parent whose rows are all closed.
It would read as open while every row stayed suppressed. Reopening the rows
is the way back, and that reopens the parent on its own.
"""
rows = doc.get("items") or []
if rows and all(cint(row.get("closed")) for row in rows):
frappe.throw(
_("Every row of {0} is closed. Reopen the rows you need instead, using {1}.").format(
frappe.bold(doc.name), frappe.bold(_("Reopen Items"))
)
)

View File

@@ -73,17 +73,14 @@ def employee_query(
.where(Criterion.any(search_conditions))
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Employee.name)) > 0,
Locate(Lower(txt_no_percent), Lower(Employee.name)),
)
.when(Locate(txt_no_percent, Employee.name) > 0, Locate(txt_no_percent, Employee.name))
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)),
Locate(txt_no_percent, Employee.employee_name) > 0,
Locate(txt_no_percent, Employee.employee_name),
)
.else_(99999)
)
@@ -139,28 +136,17 @@ def lead_query(
query.where(Lead.docstatus < 2)
.where(Lead.status.isnull() | (Lead.status != "Converted"))
.where(Criterion.any(search_conditions))
.orderby(
Case().when(Locate(txt_no_percent, Lead.name) > 0, Locate(txt_no_percent, Lead.name)).else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Lead.name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.name)),
)
.when(Locate(txt_no_percent, Lead.lead_name) > 0, Locate(txt_no_percent, Lead.lead_name))
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)),
)
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Lead.company_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.company_name)),
)
.when(Locate(txt_no_percent, Lead.company_name) > 0, Locate(txt_no_percent, Lead.company_name))
.else_(99999)
)
.orderby(Lead.idx, order=Order.desc)
@@ -401,12 +387,7 @@ def bom(
.where(BOM.is_active == 1)
.where(BOM[searchfield].like(f"%{txt}%"))
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(BOM.name)) > 0,
Locate(Lower(txt_no_percent), Lower(BOM.name)),
)
.else_(99999)
Case().when(Locate(txt_no_percent, BOM.name) > 0, Locate(txt_no_percent, BOM.name)).else_(99999)
)
.orderby(BOM.idx, order=Order.desc)
.orderby(BOM.name)

View File

@@ -160,28 +160,10 @@ def validate_returned_items(doc):
):
frappe.throw(_("Warehouse is mandatory"))
if doc.doctype in (
"Purchase Invoice",
"Purchase Receipt",
"Subcontracting Receipt",
"Sales Invoice",
"Delivery Note",
"POS Invoice",
):
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
items_returned = True
else:
items_returned = True
items_returned = True
elif d.item_name:
if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"):
# No item_code here means no linked Item, so there's no accepted/rejected
# split to speak of - received_qty isn't a meaningful independent signal.
# Only a negative qty (i.e. a real negative billing amount) counts.
if flt(d.qty) < 0:
items_returned = True
else:
items_returned = True
items_returned = True
if not items_returned:
frappe.throw(_("At least one item should be entered with negative quantity in return document"))

View File

@@ -8,8 +8,6 @@ from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import comma_or, flt, get_link_to_form, getdate, now, nowdate, safe_div
from erpnext.controllers.item_close import closed_rows_settle, has_closable_items
class OverAllowanceError(frappe.ValidationError):
pass
@@ -194,60 +192,9 @@ class StatusUpdater(Document):
self.db_set("status", "Cancelled")
def update_prevdoc_status(self):
self.validate_closed_source_items()
self.update_qty()
self.validate_qty()
def get_closed_source_links(self):
"""Row links that must not point at a closed source row.
`status_updater` covers documents whose progress it already tracks.
Delivery Note and Purchase Receipt are billed through their own services
instead, so their invoices declare the link in `closed_source_links`.
"""
links = [
(args["source_dt"], args["join_field"], args["target_dt"], args["target_parent_dt"])
for args in self.status_updater
if args.get("target_dt")
and args.get("target_parent_dt")
and has_closable_items(args["target_parent_dt"])
]
return links + list(getattr(self, "closed_source_links", []))
def validate_closed_source_items(self):
"""Block submitting against rows that were closed on the source document."""
if self.docstatus != 1:
return
for source_dt, join_field, target_dt, target_parent_dt in self.get_closed_source_links():
if not frappe.get_meta(target_dt).has_field("closed"):
continue
row_idx = {}
for d in self.get_all_children(source_dt):
if d.get(join_field):
row_idx[d.get(join_field)] = d.idx
if not row_idx:
continue
closed_rows = frappe.get_all(
target_dt,
filters={"name": ("in", list(row_idx)), "closed": 1},
fields=["name", "item_code", "parent"],
)
for row in closed_rows:
frappe.throw(
_("Row #{0}: Item {1} is closed in {2} {3} and cannot be processed further").format(
row_idx[row.name],
frappe.bold(row.item_code),
_(target_parent_dt),
frappe.bold(row.parent),
)
)
def set_status(self, update=False, status=None, update_modified=True):
if self.is_new():
if self.get("amended_from"):
@@ -499,12 +446,11 @@ class StatusUpdater(Document):
else (0, {}, None, None)
)
role = None
if qty_or_amount == "qty":
if args.get("overflow_type") in ("delivery", "receipt"):
role = frappe.get_single_value("Stock Settings", "role_allowed_to_over_deliver_receive")
else:
role = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
role_allowed_to_over_deliver_receive = frappe.get_single_value(
"Stock Settings", "role_allowed_to_over_deliver_receive"
)
role_allowed_to_over_bill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
role = role_allowed_to_over_deliver_receive if qty_or_amount == "qty" else role_allowed_to_over_bill
overflow_percent = (
(item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]]
@@ -658,36 +604,24 @@ class StatusUpdater(Document):
@staticmethod
def _calculate_target_parent_percentage(
name, target_parent_dt, target_dt, target_ref_field, target_field, target_parent_field
name, target_parent_dt, target_dt, target_ref_field, target_field
):
tracks_closed_rows = closed_rows_settle(target_parent_dt, target_dt, target_parent_field)
fields = [target_ref_field, target_field]
if tracks_closed_rows:
fields.append("closed")
child_records = frappe.get_all(
target_dt,
filters={"parent": name, "parenttype": target_parent_dt},
fields=fields,
fields=[target_ref_field, target_field],
)
# For operator dicts, the alias is in the "as" key; for strings, use the field name directly
ref_key = target_ref_field.get("as") if isinstance(target_ref_field, dict) else target_ref_field
# A closed row is written off, so it leaves the denominator rather than
# counting as done. The percentage stays a true measure of what was
# actually received, delivered or billed against what is still expected.
# Once every row is written off there is nothing left to measure against,
# so fall back to the whole table and report what actually happened.
open_records = [r for r in child_records if not (tracks_closed_rows and r["closed"])]
basis = open_records or child_records
sum_ref = sum(abs(record[ref_key]) for record in basis)
sum_ref = sum(abs(record[ref_key]) for record in child_records)
if sum_ref > 0:
percentage = round(
sum(min(abs(record[target_field]), abs(record[ref_key])) for record in basis) / sum_ref * 100,
sum(min(abs(record[target_field]), abs(record[ref_key])) for record in child_records)
/ sum_ref
* 100,
6,
)
else:
@@ -733,7 +667,6 @@ class StatusUpdater(Document):
args["target_dt"],
args["target_ref_field"],
args["target_field"],
args["target_parent_field"],
)
# update field
if args.get("status_field"):

View File

@@ -1,231 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import add_days, flt, nowdate
from erpnext.buying.doctype.purchase_order.mapper import (
get_mapped_purchase_invoice,
make_purchase_receipt,
)
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.controllers.item_close import update_closed_status
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "_Test Warehouse - _TC"
def get_ordered_qty(item_code):
return flt(frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": WAREHOUSE}, "ordered_qty"))
class TestPurchaseOrderItemClose(ERPNextTestSuite):
def setUp(self):
self.first_item = make_item(properties={"is_stock_item": 1}).name
self.second_item = make_item(properties={"is_stock_item": 1}).name
def make_purchase_order(self):
po = create_purchase_order(item_code=self.first_item, qty=10, rate=100, do_not_save=True)
po.append(
"items",
{
"item_code": self.second_item,
"warehouse": WAREHOUSE,
"qty": 10,
"rate": 100,
"schedule_date": add_days(nowdate(), 1),
},
)
po.set_missing_values()
po.insert()
po.submit()
return po
def close_items(self, po, rows, closed=1):
update_closed_status("Purchase Order", po.name, [row.name for row in rows], closed)
po.reload()
def test_closing_row_releases_ordered_qty(self):
po = self.make_purchase_order()
self.assertEqual(get_ordered_qty(self.second_item), 10)
self.close_items(po, [po.items[1]])
self.assertEqual(get_ordered_qty(self.second_item), 0)
self.assertEqual(get_ordered_qty(self.first_item), 10)
def test_closing_row_settles_receiving_percentage(self):
po = self.make_purchase_order()
receipt = make_purchase_receipt(po.name)
receipt.items = [item for item in receipt.items if item.item_code == self.first_item]
receipt.insert()
receipt.submit()
po.reload()
self.assertEqual(po.per_received, 50)
self.assertEqual(po.status, "To Receive and Bill")
self.close_items(po, [po.items[1]])
self.assertEqual(po.per_received, 100)
self.assertEqual(po.status, "To Bill")
def test_closing_every_row_closes_the_order(self):
po = self.make_purchase_order()
self.close_items(po, po.items)
self.assertEqual(po.status, "Closed")
self.assertEqual(get_ordered_qty(self.first_item), 0)
self.assertEqual(get_ordered_qty(self.second_item), 0)
def test_parent_reopen_is_blocked_when_all_rows_are_closed(self):
po = self.make_purchase_order()
self.close_items(po, po.items)
self.assertRaises(frappe.ValidationError, po.update_status, "Submitted")
po.reload()
self.assertEqual(po.status, "Closed")
self.assertTrue(all(row.closed for row in po.items))
def test_reopening_all_rows_restores_the_order(self):
po = self.make_purchase_order()
self.close_items(po, po.items)
self.assertEqual(po.status, "Closed")
self.close_items(po, po.items, closed=0)
self.assertFalse(any(row.closed for row in po.items))
self.assertEqual(po.per_received, 0)
self.assertEqual(po.status, "To Receive and Bill")
self.assertEqual(get_ordered_qty(self.first_item), 10)
def test_reopening_one_row_reopens_the_parent(self):
po = self.make_purchase_order()
self.close_items(po, po.items)
self.close_items(po, [po.items[1]], closed=0)
self.assertEqual(po.status, "To Receive and Bill")
self.assertTrue(po.items[0].closed)
self.assertFalse(po.items[1].closed)
# nothing received, and the closed row is written off rather than counted
self.assertEqual(po.per_received, 0)
self.assertEqual(get_ordered_qty(self.second_item), 10)
self.assertEqual(get_ordered_qty(self.first_item), 0)
def test_settled_row_cannot_be_closed(self):
po = self.make_purchase_order()
receipt = make_purchase_receipt(po.name)
receipt.insert()
receipt.submit()
invoice = get_mapped_purchase_invoice(po.name)
invoice.insert()
invoice.submit()
po.reload()
self.assertEqual(po.status, "Completed")
self.assertRaises(frappe.ValidationError, self.close_items, po, [po.items[0]])
def test_received_but_unbilled_row_can_be_closed(self):
po = self.make_purchase_order()
receipt = make_purchase_receipt(po.name)
receipt.insert()
receipt.submit()
po.reload()
self.assertEqual(po.status, "To Bill")
self.close_items(po, po.items)
# billing written off, but the goods really did arrive
self.assertEqual(po.per_billed, 0)
self.assertEqual(po.per_received, 100)
self.assertEqual(po.status, "Closed")
def test_receipt_is_not_offered_when_the_rest_is_closed(self):
po = self.make_purchase_order()
receipt = make_purchase_receipt(po.name)
receipt.items = [item for item in receipt.items if item.item_code == self.first_item]
receipt.insert()
receipt.submit()
po.reload()
self.close_items(po, [po.items[1]])
self.assertEqual(po.status, "To Bill")
self.assertFalse(po.has_pending_receivable_qty())
self.assertFalse(make_purchase_receipt(po.name).get("items"))
def test_reopening_partly_closed_order_keeps_row_flags(self):
po = self.make_purchase_order()
self.close_items(po, [po.items[1]])
po.update_status("Closed")
po.reload()
self.assertEqual(po.status, "Closed")
po.update_status("Submitted")
po.reload()
self.assertFalse(po.items[0].closed)
self.assertTrue(po.items[1].closed)
self.assertEqual(get_ordered_qty(self.first_item), 10)
self.assertEqual(get_ordered_qty(self.second_item), 0)
def test_closed_row_is_not_mapped_to_purchase_receipt(self):
po = self.make_purchase_order()
self.close_items(po, [po.items[1]])
receipt = make_purchase_receipt(po.name)
self.assertEqual([item.item_code for item in receipt.items], [self.first_item])
def test_receiving_a_closed_row_is_blocked(self):
po = self.make_purchase_order()
receipt = make_purchase_receipt(po.name)
self.close_items(po, [po.items[1]])
receipt.insert()
self.assertRaises(frappe.ValidationError, receipt.submit)
def test_reopening_a_row_restores_pending_qty(self):
po = self.make_purchase_order()
self.close_items(po, [po.items[1]])
self.assertEqual(get_ordered_qty(self.second_item), 0)
self.close_items(po, [po.items[1]], closed=0)
self.assertEqual(get_ordered_qty(self.second_item), 10)
self.assertEqual(po.per_received, 0)
self.assertEqual(po.status, "To Receive and Bill")
def test_closing_is_rejected_for_unsupported_doctype(self):
self.assertRaises(
frappe.ValidationError,
update_closed_status,
"Material Request",
"any-name",
["any-row"],
1,
)
def test_amending_clears_closed_rows(self):
"""Frappe keeps no_copy fields when amending, so the flag must be cleared."""
po = self.make_purchase_order()
self.close_items(po, [po.items[1]])
po.cancel()
amended = frappe.copy_doc(po, ignore_no_copy=True)
amended.docstatus = 0
amended.amended_from = po.name
amended.insert()
self.assertFalse(any(row.closed for row in amended.items))

View File

@@ -1,248 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt
from erpnext.controllers.item_close import update_closed_status
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "_Test Warehouse - _TC"
class TestPurchaseReceiptItemClose(ERPNextTestSuite):
def setUp(self):
self.first_item = make_item(properties={"is_stock_item": 1}).name
self.second_item = make_item(properties={"is_stock_item": 1}).name
def make_purchase_receipt(self):
receipt = make_purchase_receipt(
item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_submit=True
)
receipt.append(
"items",
{
"item_code": self.second_item,
"warehouse": WAREHOUSE,
"qty": 10,
"rate": 100,
},
)
receipt.save()
receipt.submit()
return receipt
def close_items(self, doc, rows, closed=1):
update_closed_status(doc.doctype, doc.name, [row.name for row in rows], closed)
doc.reload()
def test_closing_a_row_does_not_inflate_billing_percentage(self):
receipt = self.make_purchase_receipt()
self.assertEqual(receipt.per_billed, 0)
self.close_items(receipt, [receipt.items[1]])
# nothing was billed, so the receipt must not read as partly billed
self.assertEqual(receipt.per_billed, 0)
self.assertEqual(receipt.status, "To Bill")
def test_closing_every_row_closes_the_receipt(self):
receipt = self.make_purchase_receipt()
self.close_items(receipt, receipt.items)
# nothing was billed, and writing every row off must not claim otherwise
self.assertEqual(receipt.per_billed, 0)
self.assertEqual(receipt.status, "Closed")
def test_closed_row_is_not_mapped_to_purchase_invoice(self):
receipt = self.make_purchase_receipt()
self.close_items(receipt, [receipt.items[1]])
invoice = make_purchase_invoice(receipt.name)
self.assertEqual([item.item_code for item in invoice.items], [self.first_item])
def test_billing_a_closed_row_is_blocked(self):
receipt = self.make_purchase_receipt()
invoice = make_purchase_invoice(receipt.name)
self.close_items(receipt, [receipt.items[1]])
invoice.insert()
self.assertRaises(frappe.ValidationError, invoice.submit)
def test_parent_reopen_is_blocked_when_all_rows_are_closed(self):
receipt = self.make_purchase_receipt()
self.close_items(receipt, receipt.items)
self.assertRaises(frappe.ValidationError, receipt.update_status, "Submitted")
def test_reopening_one_row_reopens_the_receipt(self):
receipt = self.make_purchase_receipt()
self.close_items(receipt, receipt.items)
self.close_items(receipt, [receipt.items[1]], closed=0)
self.assertNotEqual(receipt.status, "Closed")
self.assertEqual(receipt.per_billed, 0)
def test_unbilled_return_row_can_be_closed(self):
"""Return rows are closable by design, not by an accident of sign."""
receipt = self.make_purchase_receipt()
return_receipt = make_return_doc("Purchase Receipt", receipt.name)
return_receipt.insert()
return_receipt.submit()
row = return_receipt.items[0]
self.assertLess(row.amount, 0)
self.assertTrue(return_receipt.is_item_closable(row))
self.close_items(return_receipt, [row])
self.assertTrue(return_receipt.items[0].closed)
class TestDeliveryNoteItemClose(ERPNextTestSuite):
def setUp(self):
self.first_item = make_item(properties={"is_stock_item": 1}).name
self.second_item = make_item(properties={"is_stock_item": 1}).name
for item_code in (self.first_item, self.second_item):
make_stock_entry(item_code=item_code, target=WAREHOUSE, qty=100, basic_rate=50)
def make_delivery_note(self):
note = create_delivery_note(
item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_save=True
)
note.append(
"items",
{
"item_code": self.second_item,
"warehouse": WAREHOUSE,
"qty": 10,
"rate": 100,
},
)
note.insert()
note.submit()
return note
def close_items(self, doc, rows, closed=1):
update_closed_status(doc.doctype, doc.name, [row.name for row in rows], closed)
doc.reload()
def test_closing_a_row_does_not_inflate_billing_percentage(self):
note = self.make_delivery_note()
self.assertEqual(note.per_billed, 0)
self.close_items(note, [note.items[1]])
# nothing was billed, so the note must not read as partially billed
self.assertEqual(note.per_billed, 0)
self.assertEqual(note.status, "To Bill")
def test_closing_every_row_closes_the_note(self):
note = self.make_delivery_note()
self.close_items(note, note.items)
# nothing was billed, and writing every row off must not claim otherwise
self.assertEqual(note.per_billed, 0)
self.assertEqual(note.status, "Closed")
def test_closed_row_is_not_mapped_to_sales_invoice(self):
note = self.make_delivery_note()
self.close_items(note, [note.items[1]])
invoice = make_sales_invoice(note.name)
self.assertEqual([item.item_code for item in invoice.items], [self.first_item])
def test_billing_a_closed_row_is_blocked(self):
note = self.make_delivery_note()
invoice = make_sales_invoice(note.name)
self.close_items(note, [note.items[1]])
invoice.insert()
self.assertRaises(frappe.ValidationError, invoice.submit)
def test_closing_a_row_does_not_mark_it_returned(self):
note = self.make_delivery_note()
self.close_items(note, note.items)
self.assertEqual(note.per_returned, 0)
self.assertEqual(note.status, "Closed")
def test_amending_clears_closed_rows(self):
"""Frappe keeps no_copy fields when amending, so the flag must be cleared."""
note = self.make_delivery_note()
self.close_items(note, [note.items[1]])
note.cancel()
amended = frappe.copy_doc(note, ignore_no_copy=True)
amended.docstatus = 0
amended.amended_from = note.name
amended.insert()
self.assertFalse(any(row.closed for row in amended.items))
def test_noncanonical_closed_value_is_normalised(self):
"""A truthy non-1 value must not slip past the exact-match submission guard."""
note = self.make_delivery_note()
update_closed_status("Delivery Note", note.name, [note.items[1].name], 2)
note.reload()
self.assertEqual(note.items[1].closed, 1)
def test_unbilled_return_row_can_be_closed(self):
"""Return rows carry negative amounts and must still be closable."""
note = self.make_delivery_note()
return_note = make_return_doc("Delivery Note", note.name)
return_note.insert()
return_note.submit()
row = return_note.items[0]
self.assertLess(row.amount, 0)
self.assertTrue(return_note.is_item_closable(row))
self.close_items(return_note, [row])
self.assertTrue(return_note.items[0].closed)
def test_return_row_pending_amount_is_a_magnitude(self):
"""The dialog shows what is outstanding, so a return row must not read as zero."""
note = self.make_delivery_note()
return_note = make_return_doc("Delivery Note", note.name)
return_note.insert()
return_note.submit()
row = return_note.items[0]
self.assertLess(row.amount, 0)
pending = abs(flt(row.amount)) - abs(flt(row.billed_amt))
self.assertEqual(pending, abs(flt(note.items[0].amount)))
self.assertGreater(pending, 0)
def test_closing_a_return_row_leaves_the_original_untouched(self):
"""Writing off a credit note must not disturb what was returned."""
note = self.make_delivery_note()
return_note = make_return_doc("Delivery Note", note.name)
return_note.insert()
return_note.submit()
note.reload()
before = [(row.returned_qty, row.closed) for row in note.items]
per_returned_before = note.per_returned
self.close_items(return_note, [return_note.items[0]])
note.reload()
self.assertEqual([(row.returned_qty, row.closed) for row in note.items], before)
self.assertEqual(note.per_returned, per_returned_before)

View File

@@ -1,141 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import add_days, flt, nowdate
from erpnext.controllers.item_close import update_closed_status
from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "_Test Warehouse - _TC"
def get_reserved_qty(item_code):
return flt(frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": WAREHOUSE}, "reserved_qty"))
class TestSalesOrderItemClose(ERPNextTestSuite):
def setUp(self):
self.first_item = make_item(properties={"is_stock_item": 1}).name
self.second_item = make_item(properties={"is_stock_item": 1}).name
for item_code in (self.first_item, self.second_item):
make_stock_entry(item_code=item_code, target=WAREHOUSE, qty=100, basic_rate=50)
def make_sales_order(self):
so = make_sales_order(
item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_submit=True
)
so.append(
"items",
{
"item_code": self.second_item,
"warehouse": WAREHOUSE,
"qty": 10,
"rate": 100,
"delivery_date": add_days(nowdate(), 1),
},
)
so.save()
so.submit()
return so
def close_items(self, so, rows, closed=1):
update_closed_status("Sales Order", so.name, [row.name for row in rows], closed)
so.reload()
def test_closing_row_releases_reserved_qty(self):
so = self.make_sales_order()
self.assertEqual(get_reserved_qty(self.second_item), 10)
self.close_items(so, [so.items[1]])
self.assertEqual(get_reserved_qty(self.second_item), 0)
self.assertEqual(get_reserved_qty(self.first_item), 10)
def test_closing_row_settles_delivery_percentage(self):
so = self.make_sales_order()
note = make_delivery_note(so.name)
note.items = [item for item in note.items if item.item_code == self.first_item]
note.insert()
note.submit()
so.reload()
self.assertEqual(so.per_delivered, 50)
self.close_items(so, [so.items[1]])
self.assertEqual(so.per_delivered, 100)
self.assertEqual(so.delivery_status, "Fully Delivered")
def test_closing_every_row_closes_the_order(self):
so = self.make_sales_order()
self.close_items(so, so.items)
self.assertEqual(so.status, "Closed")
self.assertEqual(get_reserved_qty(self.first_item), 0)
self.assertEqual(get_reserved_qty(self.second_item), 0)
def test_reopening_one_row_reopens_the_parent(self):
so = self.make_sales_order()
self.close_items(so, so.items)
self.close_items(so, [so.items[1]], closed=0)
self.assertNotEqual(so.status, "Closed")
self.assertTrue(so.items[0].closed)
self.assertFalse(so.items[1].closed)
self.assertEqual(get_reserved_qty(self.second_item), 10)
self.assertEqual(get_reserved_qty(self.first_item), 0)
def test_parent_reopen_is_blocked_when_all_rows_are_closed(self):
so = self.make_sales_order()
self.close_items(so, so.items)
self.assertRaises(frappe.ValidationError, so.update_status, "Draft")
so.reload()
self.assertEqual(so.status, "Closed")
def test_closed_row_is_not_mapped_to_delivery_note(self):
so = self.make_sales_order()
self.close_items(so, [so.items[1]])
note = make_delivery_note(so.name)
self.assertEqual([item.item_code for item in note.items], [self.first_item])
def test_closed_row_is_not_mapped_to_sales_invoice(self):
so = self.make_sales_order()
self.close_items(so, [so.items[1]])
invoice = make_sales_invoice(so.name)
self.assertEqual([item.item_code for item in invoice.items], [self.first_item])
def test_delivering_a_closed_row_is_blocked(self):
so = self.make_sales_order()
note = make_delivery_note(so.name)
self.close_items(so, [so.items[1]])
note.insert()
self.assertRaises(frappe.ValidationError, note.submit)
def test_settled_row_cannot_be_closed(self):
so = self.make_sales_order()
note = make_delivery_note(so.name)
note.insert()
note.submit()
invoice = make_sales_invoice(so.name)
invoice.insert()
invoice.submit()
so.reload()
self.assertRaises(frappe.ValidationError, self.close_items, so, [so.items[0]])

View File

@@ -29,27 +29,6 @@ class TestQueries(ERPNextTestSuite):
self.assertGreaterEqual(len(query(txt="_Test Lead")), 4)
self.assertEqual(len(query(txt="_Test Lead 4")), 1)
def test_lead_query_ranking_is_case_insensitive(self):
"""A match at the start must rank first whatever its case.
The filter uses .like(), which frappe renders as ILIKE on PostgreSQL, so both leads match.
Ranking used a bare Locate(), which becomes case-sensitive strpos() there: the upper-cased
lead scores no match, falls back to 99999 and sorts last, while MariaDB's case-insensitive
LOCATE ranks it first. Same query, different order -- and a different page when page_len is
small enough to cut between them.
"""
early, late = "ZZABCD Ranking Lead", "Ranking Lead zzabcd"
for lead_name in (early, late):
if not frappe.db.exists("Lead", {"lead_name": lead_name}):
frappe.get_doc({"doctype": "Lead", "lead_name": lead_name}).insert()
query = add_default_params(queries.lead_query, "Lead")
names = [row[1] for row in query(txt="zzabcd")]
self.assertIn(early, names)
self.assertIn(late, names)
self.assertLess(names.index(early), names.index(late))
def test_item_query(self):
query = add_default_params(queries.item_query, "Item")

View File

@@ -37,76 +37,3 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite):
self.assertEqual(return_dn.is_return, 1)
self.assertEqual(return_dn.items[0].qty, -5)
def test_purchase_invoice_zero_qty_return_is_rejected(self):
# A return with every item at qty 0 moves no stock and no value, so it must be
# rejected the same way a return with no items at all would be.
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
pi = make_purchase_invoice(qty=10)
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
return_pi = make_purchase_invoice(
is_return=1,
return_against=pi.name,
qty=0,
do_not_save=True,
)
self.assertRaises(frappe.ValidationError, return_pi.save)
def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self):
# Item Code is not mandatory on Purchase Invoice Item - a row can have only an
# item_name (e.g. a free-text/non-stock line). Such rows fall through to the
# item_name-only branch, which must also reject an all-zero-qty return instead
# of unconditionally treating the row as returned.
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True)
pi.items[0].item_code = ""
pi.save()
pi.submit()
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
return_pi = make_purchase_invoice(
item_name="_Test Item",
is_return=1,
return_against=pi.name,
qty=0,
do_not_save=True,
)
return_pi.items[0].item_code = ""
self.assertRaises(frappe.ValidationError, return_pi.save)
def test_delivery_note_zero_qty_return_is_rejected(self):
# A return with every item at qty 0 moves no stock and no value, so it must be
# rejected the same way a return with no items at all would be.
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
dn = create_delivery_note(qty=5)
self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
return_dn = make_sales_return(dn.name)
return_dn.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_dn.insert)
def test_sales_invoice_zero_qty_return_is_rejected(self):
# Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on
# every row must be rejected, not silently accepted as a no-op credit note.
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.controllers.sales_and_purchase_return import make_return_doc
si = create_sales_invoice(qty=10)
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
return_si = make_return_doc(si.doctype, si.name)
return_si.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_si.save)

View File

@@ -376,41 +376,6 @@ def get_period_month_ranges(period, fiscal_year):
return period_month_ranges
def quotation_party_name_expr():
"""Resolve a Quotation's party label from its dynamic link, mirroring set_customer_name()."""
customer_branch = (
"when t1.quotation_to = 'Customer' then "
"(select c.customer_name from `tabCustomer` c where c.name = t1.party_name)"
)
lead_branch = (
"when t1.quotation_to = 'Lead' then "
"(select coalesce(nullif(l.company_name, ''), l.lead_name) from `tabLead` l "
"where l.name = t1.party_name)"
)
prospect_branch = "when t1.quotation_to = 'Prospect' then t1.party_name"
branches = [customer_branch, lead_branch, prospect_branch]
# CRM Deal ships with the CRM app; skip the branch when its table is absent
if frappe.db.table_exists("CRM Deal"):
branches.append(
"when t1.quotation_to = 'CRM Deal' then "
"(select d.organization from `tabCRM Deal` d where d.name = t1.party_name)"
)
return "case " + " ".join(branches) + " end"
def quotation_territory_expr():
"""Only Customer and Lead carry a territory; other party types have none."""
return (
"case "
"when t1.quotation_to = 'Customer' then "
"(select c.territory from `tabCustomer` c where c.name = t1.party_name) "
"when t1.quotation_to = 'Lead' then "
"(select l.territory from `tabLead` l where l.name = t1.party_name) "
"end"
)
def based_wise_columns_query(based_on, trans):
based_on_details = {}
@@ -420,14 +385,12 @@ def based_wise_columns_query(based_on, trans):
{"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"},
{"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"},
]
# item_name is stored per line and editable, so it is not functionally dependent on item_code
# and Max() over it is a sort -- which MariaDB and PostgreSQL resolve differently. Read it
# from the Item master instead: that IS functionally dependent on the grouped item_code, so
# it can be grouped without splitting rows and is identical on both engines by construction.
based_on_details["based_on_select"] = "t2.item_code, item_master.item_name as item_name,"
based_on_details["based_on_group_by"] = "t2.item_code, item_master.item_name"
based_on_details["addl_tables"] = ",`tabItem` item_master"
based_on_details["addl_tables_relational_cond"] = " and t2.item_code = item_master.name"
# item_name is an editable per-line field, not functionally dependent on item_code, so it
# is aggregated (one row per item_code) rather than added to GROUP BY (which would split
# the row and change the MariaDB row count). See get_data's group-by query.
based_on_details["based_on_select"] = "t2.item_code, Max(t2.item_name) as item_name,"
based_on_details["based_on_group_by"] = "t2.item_code"
based_on_details["addl_tables"] = ""
elif based_on == "Item Group":
based_on_details["based_on_cols"] = [
@@ -462,17 +425,9 @@ def based_wise_columns_query(based_on, trans):
"fieldname": "territory",
},
]
# a Quotation's party_name is a dynamic link, so no single master can be joined. Resolve
# it through the quotation_to discriminator, mirroring Quotation.set_customer_name, and
# group by it too: two parties of different types can share a name, and merging them
# under one row was never right. Correlated only on grouped columns, so the query stays
# valid under GROUP BY and free of any text sort.
based_on_details["based_on_select"] = (
f"t1.party_name, {quotation_party_name_expr()} as customer_name, "
f"{quotation_territory_expr()} as territory,"
)
based_on_details["based_on_group_by"] = "t1.party_name, t1.quotation_to"
based_on_details["addl_tables"] = ""
based_on_details[
"based_on_select"
] = "t1.party_name, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory,"
else:
based_on_details["based_on_cols"] = [
{
@@ -496,19 +451,13 @@ def based_wise_columns_query(based_on, trans):
"fieldname": "territory",
},
]
# customer_name and territory are stored per transaction and editable, so they are not
# functionally dependent on the customer and Max() over them is a text sort, which the
# engines resolve differently. The Customer master's values ARE dependent on the grouped
# key, so they can be grouped without splitting rows and agree on both engines.
based_on_details["based_on_select"] = (
"t1.customer, customer_master.customer_name as customer_name, "
"customer_master.territory as territory,"
)
based_on_details[
"based_on_group_by"
] = "t1.customer, customer_master.customer_name, customer_master.territory"
based_on_details["addl_tables"] = ",`tabCustomer` customer_master"
based_on_details["addl_tables_relational_cond"] = " and t1.customer = customer_master.name"
"based_on_select"
] = "t1.customer, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory,"
# territory (and customer_name) are not functionally dependent on the customer key, so they
# are aggregated rather than grouped — one row per customer, matching the prior MariaDB output.
based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"
based_on_details["addl_tables"] = ""
elif based_on == "Customer Group":
based_on_details["based_on_cols"] = [
@@ -541,12 +490,14 @@ def based_wise_columns_query(based_on, trans):
"fieldname": "supplier_group",
},
]
# supplier_name is stored per transaction and editable, so Max() over it is a text sort that
# the engines resolve differently. The Supplier master is already joined here as t3 and its
# columns are functionally dependent on the grouped supplier, so both can simply be grouped:
# no row split, and identical on both engines by construction.
based_on_details["based_on_select"] = "t1.supplier, t3.supplier_name, t3.supplier_group,"
based_on_details["based_on_group_by"] = "t1.supplier, t3.supplier_name, t3.supplier_group"
# supplier_name is a stored per-transaction field (not functionally dependent on supplier), so
# it is aggregated to keep one row per supplier — matching the prior MariaDB output, which grouped
# by t1.supplier only. supplier_group comes from the joined master and is FD on supplier, so it
# stays in GROUP BY (postgres-valid, no row split).
based_on_details[
"based_on_select"
] = "t1.supplier, Max(t1.supplier_name) as supplier_name, t3.supplier_group,"
based_on_details["based_on_group_by"] = "t1.supplier, t3.supplier_group"
based_on_details["addl_tables"] = ",`tabSupplier` t3"
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"

View File

@@ -133,7 +133,6 @@ class Opportunity(TransactionBase, CRMNote):
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.map_fields()
self.validate_qty()
self.set_exchange_rate()
if not self.title:
@@ -144,15 +143,6 @@ class Opportunity(TransactionBase, CRMNote):
def on_update(self):
self.update_prospect()
def validate_qty(self):
for item in self.items:
if flt(item.qty) <= 0:
frappe.throw(
_("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
item.idx, item.item_code
)
)
def map_fields(self):
for field in self.meta.get_valid_columns():
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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