mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-10 13:11:47 +00:00
Compare commits
8 Commits
l10n_devel
...
job-card-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c131d5819 | ||
|
|
e042d09975 | ||
|
|
2565a56ade | ||
|
|
3fab303e51 | ||
|
|
6ef498e352 | ||
|
|
9815d90b0f | ||
|
|
fb763848da | ||
|
|
c2654c1380 |
7
.github/POSTGRES_COMPATIBILITY.md
vendored
7
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -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.
|
||||
|
||||
25
.github/workflows/patch.yml
vendored
25
.github/workflows/patch.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,148 +6,88 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import (
|
||||
get_outstanding_reference_documents,
|
||||
get_payment_entry,
|
||||
)
|
||||
from erpnext.utilities.bulk_transaction import transaction_processing
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_payment_entries(invoices: str | list | None = None):
|
||||
def create_payment_entries(
|
||||
grouped_invoices: str | list | None = None,
|
||||
ungrouped_invoices: str | list | None = None,
|
||||
):
|
||||
"""Create draft Payment Entries from AP report invoice selection."""
|
||||
frappe.has_permission("Payment Entry", "create", throw=True)
|
||||
|
||||
names = [d["voucher_no"] for d in frappe.parse_json(invoices or "[]") if d.get("voucher_no")]
|
||||
if not names:
|
||||
grouped_invoices = [d for d in frappe.parse_json(grouped_invoices or "[]") if d.get("voucher_no")]
|
||||
ungrouped_invoices = [d for d in frappe.parse_json(ungrouped_invoices or "[]") if d.get("voucher_no")]
|
||||
|
||||
if not grouped_invoices and not ungrouped_invoices:
|
||||
frappe.throw(_("No Purchase Invoices selected"))
|
||||
|
||||
payable, excluded = _partition_payable_invoices(names)
|
||||
if not payable:
|
||||
frappe.throw(_("None of the selected invoices are payable"))
|
||||
if ungrouped_invoices:
|
||||
data = [{"name": d["voucher_no"]} for d in ungrouped_invoices]
|
||||
transaction_processing(data, "Purchase Invoice", "Payment Entry")
|
||||
|
||||
# invoices sharing a (supplier, payable account) are combined into one Payment Entry
|
||||
groups = {}
|
||||
for d in payable:
|
||||
key = (d["supplier"], d["party_account"])
|
||||
groups.setdefault(
|
||||
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
|
||||
)["vouchers"].append(d["voucher_no"])
|
||||
if grouped_invoices:
|
||||
groups = {}
|
||||
for d in grouped_invoices:
|
||||
key = (d["supplier"], d["party_account"])
|
||||
groups.setdefault(
|
||||
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
|
||||
)["vouchers"].append(d["voucher_no"])
|
||||
|
||||
frappe.msgprint(
|
||||
_("Started a background job to create {0} Grouped Payment Entries").format(len(groups))
|
||||
)
|
||||
frappe.enqueue(
|
||||
make_grouped_payment_entries,
|
||||
queue="long",
|
||||
timeout=1500,
|
||||
groups=list(groups.values()),
|
||||
)
|
||||
|
||||
|
||||
def make_grouped_payment_entries(groups):
|
||||
created, failed = 0, 0
|
||||
for group in groups.values():
|
||||
if _create_payment_entry(group):
|
||||
created += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
message = _("Created {0} draft Payment Entries").format(created)
|
||||
if excluded:
|
||||
message += " — " + _("{0} excluded (not payable)").format(len(excluded))
|
||||
if failed:
|
||||
message += " — " + _("{0} failed (see Error Log)").format(failed)
|
||||
frappe.msgprint(message, title=_("Bulk Payment Entries"), indicator="green")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payable_invoices(invoices: str | list | None = None):
|
||||
"""Return the live payable subset of the selected invoices for the report dialog."""
|
||||
frappe.has_permission("Payment Entry", "create", throw=True)
|
||||
|
||||
names = [d["voucher_no"] for d in frappe.parse_json(invoices or "[]") if d.get("voucher_no")]
|
||||
payable, excluded = _partition_payable_invoices(names)
|
||||
|
||||
currency = None
|
||||
if payable:
|
||||
company = frappe.get_cached_value("Purchase Invoice", payable[0]["voucher_no"], "company")
|
||||
currency = frappe.get_cached_value("Company", company, "default_currency")
|
||||
|
||||
return {"payable": payable, "excluded": excluded, "currency": currency}
|
||||
|
||||
|
||||
def _partition_payable_invoices(names):
|
||||
"""Split submitted Purchase Invoices into payable ones and excluded ones (with reason).
|
||||
|
||||
Returns are debit notes, internal transfers are inter-company, and non-positive
|
||||
outstanding means already settled — none are valid targets for a supplier payment.
|
||||
"""
|
||||
if not names:
|
||||
return [], []
|
||||
|
||||
rows = frappe.get_list(
|
||||
"Purchase Invoice",
|
||||
filters={"name": ["in", names], "docstatus": 1},
|
||||
fields=[
|
||||
"name",
|
||||
"supplier",
|
||||
"credit_to",
|
||||
"outstanding_amount",
|
||||
"conversion_rate",
|
||||
"is_return",
|
||||
"is_internal_supplier",
|
||||
],
|
||||
limit_page_length=0,
|
||||
)
|
||||
|
||||
payable, excluded = [], []
|
||||
for r in rows:
|
||||
if r.is_return:
|
||||
excluded.append({"voucher_no": r.name, "reason": _("Debit Note")})
|
||||
elif r.is_internal_supplier:
|
||||
excluded.append({"voucher_no": r.name, "reason": _("Internal Transfer")})
|
||||
elif flt(r.outstanding_amount) <= 0:
|
||||
excluded.append({"voucher_no": r.name, "reason": _("Already Paid")})
|
||||
else:
|
||||
payable.append(
|
||||
{
|
||||
"voucher_no": r.name,
|
||||
"supplier": r.supplier,
|
||||
"party_account": r.credit_to,
|
||||
"outstanding": flt(r.outstanding_amount) * flt(r.conversion_rate or 1),
|
||||
}
|
||||
)
|
||||
|
||||
# names not returned were cancelled/deleted or no longer readable after the report loaded
|
||||
found = {r.name for r in rows}
|
||||
for name in names:
|
||||
if name not in found:
|
||||
excluded.append({"voucher_no": name, "reason": _("Not available")})
|
||||
|
||||
return payable, excluded
|
||||
|
||||
|
||||
def _create_payment_entry(group):
|
||||
supplier = group["supplier"]
|
||||
try:
|
||||
frappe.db.savepoint("bulk_pe")
|
||||
if len(group["vouchers"]) == 1:
|
||||
pe = _build_single_payment_entry(group["vouchers"][0])
|
||||
else:
|
||||
for group in groups:
|
||||
supplier = group["supplier"]
|
||||
try:
|
||||
frappe.db.savepoint("bulk_pe")
|
||||
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
|
||||
if not pe:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
failed += 1
|
||||
frappe.log_error(
|
||||
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
|
||||
message=_(
|
||||
"No outstanding invoices found for the selected vouchers in account {0}"
|
||||
).format(group["party_account"]),
|
||||
)
|
||||
continue
|
||||
|
||||
if not pe:
|
||||
pe.flags.ignore_validate = True
|
||||
pe.set_title_field()
|
||||
pe.insert(ignore_mandatory=True)
|
||||
created += 1
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
frappe.log_error(
|
||||
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
|
||||
message=_("No outstanding amount for the selected invoice(s)."),
|
||||
)
|
||||
return False
|
||||
failed += 1
|
||||
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
|
||||
|
||||
pe.flags.ignore_validate = True
|
||||
pe.set_title_field()
|
||||
pe.insert(ignore_mandatory=True)
|
||||
return True
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
|
||||
return False
|
||||
message = _("Created {0} draft Grouped Payment Entries").format(created)
|
||||
|
||||
if failed:
|
||||
message += " — " + _("{0} skipped (see Error Log)").format(failed)
|
||||
|
||||
def _build_single_payment_entry(name):
|
||||
pe = get_payment_entry("Purchase Invoice", name)
|
||||
# guard against a stale report row: nothing to allocate means the invoice is already settled
|
||||
if not pe.references or not any(flt(r.allocated_amount) for r in pe.references):
|
||||
return None
|
||||
return pe
|
||||
frappe.publish_realtime(
|
||||
"msgprint",
|
||||
{"message": message, "title": _("Bulk Payment Entries"), "indicator": "green"},
|
||||
user=frappe.session.user,
|
||||
after_commit=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_grouped_payment_entry(supplier, party_account, names):
|
||||
name_set = set(names)
|
||||
pe = get_payment_entry("Purchase Invoice", names[0])
|
||||
pe.set("references", [])
|
||||
|
||||
@@ -161,9 +101,8 @@ def _build_grouped_payment_entry(supplier, party_account, names):
|
||||
}
|
||||
)
|
||||
|
||||
# get_negative_outstanding_invoices ignores the vouchers filter, so bound refs to the selection
|
||||
for r in refs:
|
||||
if r.voucher_type != "Purchase Invoice" or r.voucher_no not in name_set:
|
||||
if r.voucher_type != "Purchase Invoice":
|
||||
continue
|
||||
pe.append(
|
||||
"references",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -306,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
|
||||
@@ -320,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"))
|
||||
@@ -830,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():
|
||||
@@ -959,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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1165,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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -161,14 +161,7 @@ class ShippingRule(Document):
|
||||
)
|
||||
shipping_charge["add_deduct_tax"] = "Add"
|
||||
|
||||
shipping_charge_filters = shipping_charge.copy()
|
||||
if not self.cost_center:
|
||||
shipping_charge_filters["cost_center"] = (
|
||||
"in",
|
||||
(None, "", erpnext.get_default_cost_center(doc.company)),
|
||||
)
|
||||
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge_filters)
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
|
||||
if existing_shipping_charge:
|
||||
# take the last record found
|
||||
existing_shipping_charge[-1].tax_amount = shipping_amount
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -234,36 +234,20 @@ function create_payment_entries_from_payable_report(report) {
|
||||
return;
|
||||
}
|
||||
|
||||
// validate against live state: only unpaid/partly-paid invoices with real outstanding are payable
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.bulk_payment.get_payable_invoices",
|
||||
args: { invoices: rows.map((r) => ({ voucher_no: r.voucher_no })) },
|
||||
callback: ({ message }) => {
|
||||
const { payable = [], excluded = [], currency } = message || {};
|
||||
if (!payable.length) {
|
||||
frappe.msgprint(__("None of the selected invoices are payable"));
|
||||
return;
|
||||
}
|
||||
show_create_payment_entries_dialog(report, payable, excluded, currency);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function show_create_payment_entries_dialog(report, payable, excluded, currency) {
|
||||
// group by (supplier, party_account) for the overview — matches the backend grouping key
|
||||
// build per-(supplier, party_account) summary to match backend grouping key
|
||||
const supplierMap = {};
|
||||
for (const inv of payable) {
|
||||
const key = `${inv.supplier}||${inv.party_account}`;
|
||||
for (const r of rows) {
|
||||
const key = `${r.party}||${r.party_account}`;
|
||||
if (!supplierMap[key]) {
|
||||
supplierMap[key] = {
|
||||
supplier: inv.supplier,
|
||||
party_account: inv.party_account,
|
||||
supplier: r.party,
|
||||
party_account: r.party_account,
|
||||
count: 0,
|
||||
outstanding: 0,
|
||||
};
|
||||
}
|
||||
supplierMap[key].count += 1;
|
||||
supplierMap[key].outstanding += inv.outstanding || 0;
|
||||
supplierMap[key].outstanding += r.outstanding || 0;
|
||||
}
|
||||
|
||||
const overviewFields = [
|
||||
@@ -300,36 +284,24 @@ function show_create_payment_entries_dialog(report, payable, excluded, currency)
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [];
|
||||
if (excluded.length) {
|
||||
fields.push({ fieldtype: "HTML", fieldname: "excluded_note", options: excluded_note_html(excluded) });
|
||||
}
|
||||
fields.push({
|
||||
fieldname: "supplier_overview",
|
||||
fieldtype: "Table",
|
||||
label: __("Supplier Overview"),
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
fields: overviewFields,
|
||||
data: Object.values(supplierMap).map((d) => ({
|
||||
supplier: d.supplier,
|
||||
party_account: d.party_account,
|
||||
invoices: d.count,
|
||||
payable_amount: d.outstanding,
|
||||
})),
|
||||
});
|
||||
|
||||
const pe_count = Object.keys(supplierMap).length;
|
||||
const grand_total = Object.values(supplierMap).reduce((sum, d) => sum + d.outstanding, 0);
|
||||
fields.push({
|
||||
fieldtype: "HTML",
|
||||
fieldname: "summary_footer",
|
||||
options: summary_footer_html(pe_count, grand_total, currency),
|
||||
});
|
||||
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Create Payment Entries"),
|
||||
fields: fields,
|
||||
fields: [
|
||||
{
|
||||
fieldname: "supplier_overview",
|
||||
fieldtype: "Table",
|
||||
label: __("Supplier Overview"),
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
fields: overviewFields,
|
||||
data: Object.values(supplierMap).map((d) => ({
|
||||
supplier: d.supplier,
|
||||
party_account: d.party_account,
|
||||
invoices: d.count,
|
||||
payable_amount: d.outstanding,
|
||||
})),
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Create"),
|
||||
secondary_action_label: __("Cancel"),
|
||||
secondary_action() {
|
||||
@@ -339,15 +311,32 @@ function show_create_payment_entries_dialog(report, payable, excluded, currency)
|
||||
primary_action() {
|
||||
dialog.hide();
|
||||
|
||||
// backend re-derives supplier/party_account and grouping from live data
|
||||
const invoices = payable.map((inv) => ({ voucher_no: inv.voucher_no }));
|
||||
const groupedKeys = new Set(
|
||||
Object.values(supplierMap)
|
||||
.filter((d) => d.count > 1)
|
||||
.map((d) => `${d.supplier}||${d.party_account}`)
|
||||
);
|
||||
|
||||
const grouped_invoices = [];
|
||||
const ungrouped_invoices = [];
|
||||
for (const r of rows) {
|
||||
const payload = {
|
||||
voucher_no: r.voucher_no,
|
||||
supplier: r.party,
|
||||
party_account: r.party_account,
|
||||
};
|
||||
(groupedKeys.has(`${r.party}||${r.party_account}`)
|
||||
? grouped_invoices
|
||||
: ungrouped_invoices
|
||||
).push(payload);
|
||||
}
|
||||
|
||||
const clearSelection = () => report.datatable.rowmanager.checkAll(false);
|
||||
|
||||
frappe
|
||||
.call({
|
||||
method: "erpnext.accounts.bulk_payment.create_payment_entries",
|
||||
args: { invoices },
|
||||
args: { grouped_invoices, ungrouped_invoices },
|
||||
})
|
||||
.then(clearSelection)
|
||||
.catch(clearSelection);
|
||||
@@ -356,42 +345,6 @@ function show_create_payment_entries_dialog(report, payable, excluded, currency)
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
function summary_footer_html(pe_count, grand_total, currency) {
|
||||
return `<div style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--margin-sm);
|
||||
font-size: var(--text-sm);
|
||||
">
|
||||
<span class="text-muted">${__("Payment Entries are created as drafts for your review")}</span>
|
||||
<span>${__("{0} Payment Entries", [pe_count])} ·
|
||||
<strong>${format_currency(grand_total, currency)}</strong></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function excluded_note_html(excluded) {
|
||||
const counts = {};
|
||||
for (const e of excluded) {
|
||||
counts[e.reason] = (counts[e.reason] || 0) + 1;
|
||||
}
|
||||
const summary = Object.entries(counts)
|
||||
.map(([reason, n]) => `${n} ${reason}`)
|
||||
.join(", ");
|
||||
return `<div style="
|
||||
background-color: var(--bg-yellow);
|
||||
color: var(--text-on-yellow);
|
||||
font-size: var(--text-sm);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--padding-sm) var(--padding-md);
|
||||
margin-bottom: var(--margin-sm);
|
||||
">
|
||||
<span style="font-weight: var(--weight-medium);">${__("{0} invoice(s) excluded", [
|
||||
excluded.length,
|
||||
])}</span>: ${frappe.utils.escape_html(summary)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
erpnext.utils.add_dimensions("Accounts Payable", 10);
|
||||
|
||||
function get_party_type_options() {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -310,13 +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))
|
||||
).format(item_code, qty, itemwise_min_order_qty.get(item_code))
|
||||
)
|
||||
|
||||
def get_schedule_dates(self):
|
||||
|
||||
@@ -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,42 +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_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",
|
||||
@@ -1095,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 (
|
||||
@@ -1108,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"
|
||||
|
||||
@@ -1546,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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -1039,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,
|
||||
@@ -1075,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"),
|
||||
}
|
||||
|
||||
@@ -466,7 +466,7 @@ class BuyingController(SubcontractingController):
|
||||
self.precision("item_tax_amount", item),
|
||||
)
|
||||
|
||||
self.round_floats_in(item, do_not_round_fields=["conversion_factor"])
|
||||
self.round_floats_in(item)
|
||||
if flt(item.conversion_factor) == 0.0:
|
||||
item.conversion_factor = (
|
||||
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -446,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"]]
|
||||
|
||||
@@ -225,12 +225,7 @@ class calculate_taxes_and_totals:
|
||||
if self.doc.get("is_consolidated") or self.discount_amount_applied:
|
||||
return
|
||||
|
||||
do_not_round_fields = [
|
||||
"valuation_rate",
|
||||
"incoming_rate",
|
||||
"sales_incoming_rate",
|
||||
"conversion_factor",
|
||||
]
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
|
||||
self.calculate_item_rate(item)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
4484
erpnext/locale/ar.po
4484
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
4466
erpnext/locale/bg.po
4466
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
4694
erpnext/locale/bs.po
4694
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
4466
erpnext/locale/cs.po
4466
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
4502
erpnext/locale/da.po
4502
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
4492
erpnext/locale/de.po
4492
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
4506
erpnext/locale/eo.po
4506
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
4490
erpnext/locale/es.po
4490
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
4596
erpnext/locale/fa.po
4596
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
4478
erpnext/locale/fr.po
4478
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
4474
erpnext/locale/hi.po
4474
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
4588
erpnext/locale/hr.po
4588
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
4466
erpnext/locale/hu.po
4466
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
4474
erpnext/locale/id.po
4474
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
4482
erpnext/locale/it.po
4482
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
4480
erpnext/locale/ko.po
4480
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
4466
erpnext/locale/my.po
4466
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
4466
erpnext/locale/nb.po
4466
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
4492
erpnext/locale/nl.po
4492
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
4472
erpnext/locale/pl.po
4472
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
4466
erpnext/locale/pt.po
4466
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
64645
erpnext/locale/ro.po
64645
erpnext/locale/ro.po
File diff suppressed because it is too large
Load Diff
4496
erpnext/locale/ru.po
4496
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
4722
erpnext/locale/sl.po
4722
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
4492
erpnext/locale/sr.po
4492
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
4534
erpnext/locale/sv.po
4534
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
4492
erpnext/locale/th.po
4492
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
4486
erpnext/locale/tr.po
4486
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
4498
erpnext/locale/uz.po
4498
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
4492
erpnext/locale/vi.po
4492
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
20879
erpnext/locale/zh.po
20879
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -120,8 +120,8 @@ class BlanketOrder(Document):
|
||||
|
||||
def validate_item_qty(self):
|
||||
for d in self.items:
|
||||
if flt(d.qty) <= 0:
|
||||
frappe.throw(_("Row {0}: Quantity must be greater than zero.").format(d.idx))
|
||||
if flt(d.qty) < 0:
|
||||
frappe.throw(_("Row {0}: Quantity cannot be negative.").format(d.idx))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -149,11 +149,7 @@ def make_order(source_name: str):
|
||||
"Blanket Order",
|
||||
source_name,
|
||||
{
|
||||
"Blanket Order": {
|
||||
"doctype": doctype,
|
||||
"field_no_map": ["naming_series"],
|
||||
"postprocess": update_doc,
|
||||
},
|
||||
"Blanket Order": {"doctype": doctype, "postprocess": update_doc},
|
||||
"Blanket Order Item": {
|
||||
"doctype": doctype + " Item",
|
||||
"field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"},
|
||||
|
||||
@@ -25,7 +25,6 @@ class TestBlanketOrder(ERPNextTestSuite):
|
||||
so.submit()
|
||||
|
||||
self.assertEqual(so.doctype, "Sales Order")
|
||||
self.assertNotEqual(so.naming_series, bo.naming_series)
|
||||
self.assertEqual(len(so.get("items")), len(bo.get("items")))
|
||||
|
||||
# check the rate, quantity and updation for the ordered quantity
|
||||
@@ -51,7 +50,6 @@ class TestBlanketOrder(ERPNextTestSuite):
|
||||
po.submit()
|
||||
|
||||
self.assertEqual(po.doctype, "Purchase Order")
|
||||
self.assertNotEqual(po.naming_series, bo.naming_series)
|
||||
self.assertEqual(len(po.get("items")), len(bo.get("items")))
|
||||
|
||||
# check the rate, quantity and updation for the ordered quantity
|
||||
@@ -93,32 +91,6 @@ class TestBlanketOrder(ERPNextTestSuite):
|
||||
frappe.db.set_single_value("Buying Settings", "blanket_order_allowance", 10)
|
||||
po.submit()
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"blanket_order_allowance": 0})
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"blanket_order_allowance": 0})
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{"over_delivery_receipt_allowance": 10, "role_allowed_to_over_deliver_receive": "Stock Manager"},
|
||||
)
|
||||
def test_stock_over_delivery_role_does_not_bypass_blanket_order_allowance(self):
|
||||
test_user = frappe.get_doc("User", "test@example.com")
|
||||
test_user.add_roles("Stock Manager")
|
||||
|
||||
frappe.clear_cache()
|
||||
for blanket_order_type, doctype, date_field in (
|
||||
("Selling", "Sales Order", "delivery_date"),
|
||||
("Purchasing", "Purchase Order", "schedule_date"),
|
||||
):
|
||||
bo = make_blanket_order(blanket_order_type=blanket_order_type, quantity=100)
|
||||
frappe.flags.args.doctype = doctype
|
||||
order = make_order(bo.name)
|
||||
order.currency = get_company_currency(order.company)
|
||||
setattr(order, date_field, today())
|
||||
order.items[0].qty = 110
|
||||
|
||||
with self.set_user("test@example.com"):
|
||||
order.flags.ignore_permissions = True
|
||||
self.assertRaises(frappe.ValidationError, order.submit)
|
||||
|
||||
def test_blanket_order_over_order_aggregated_across_rows(self):
|
||||
# the over-order check should sum the same item across multiple order rows
|
||||
frappe.db.set_single_value("Selling Settings", "blanket_order_allowance", 0)
|
||||
@@ -164,26 +136,6 @@ class TestBlanketOrder(ERPNextTestSuite):
|
||||
bo = make_blanket_order(blanket_order_type="Purchasing", supplier=supplier, item_code=item_code)
|
||||
self.assertEqual(bo.items[0].party_item_code, "SUPP-PART-1")
|
||||
|
||||
def test_blanket_order_zero_quantity(self):
|
||||
bo = frappe.new_doc("Blanket Order")
|
||||
bo.blanket_order_type = "Selling"
|
||||
bo.company = "_Test Company"
|
||||
bo.customer = "_Test Customer"
|
||||
bo.from_date = today()
|
||||
bo.to_date = add_months(today(), 12)
|
||||
|
||||
bo.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 0,
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
bo.insert()
|
||||
|
||||
|
||||
def make_blanket_order(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
@@ -11,7 +11,6 @@ from frappe.model.document import Document
|
||||
from frappe.query_builder import Field
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Min, NullIf, Sum
|
||||
from frappe.utils import cint, cstr, flt, get_link_to_form, parse_json
|
||||
from frappe.utils.caching import request_cache
|
||||
from frappe.website.website_generator import WebsiteGenerator
|
||||
|
||||
import erpnext
|
||||
@@ -1209,65 +1208,7 @@ def _query_bom_items(bom, company, opts):
|
||||
query, group_by = _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods)
|
||||
# qualify + aggregate idx: bare "idx" is ambiguous across the joined tables and isn't grouped
|
||||
# (idx is unique per BOM item, so Min() preserves the original ordering) — needed for postgres
|
||||
rows = query.groupby(*group_by).orderby(Min(t.bom_item.idx)).run(as_dict=True)
|
||||
|
||||
if not opts.fetch_secondary_items:
|
||||
doctype = "BOM Explosion Item" if cint(opts.fetch_exploded) else "BOM Item"
|
||||
# key only on group-by columns that belong to the line table. stock_uom is grouped from Item
|
||||
# and can differ from the line's stored copy once an item's stock UOM is changed after the
|
||||
# BOM was submitted; keying on it would miss and blank the row. It is functionally dependent
|
||||
# on item_code anyway, so dropping it from the key loses nothing.
|
||||
keys = [field.name for field in group_by if field.table is t.bom_item]
|
||||
_apply_representative_lines(rows, doctype, bom, keys)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _line_columns_for(doctype):
|
||||
columns = ["description", "source_warehouse"]
|
||||
if doctype == "BOM Item":
|
||||
# uom only means something beside its own conversion_factor, so they travel together
|
||||
columns += ["uom", "conversion_factor"]
|
||||
return columns
|
||||
|
||||
|
||||
def _apply_representative_lines(rows, doctype, bom, keys):
|
||||
"""Fill the line-level columns from a single real BOM line per group.
|
||||
|
||||
They describe a line, not an item, so a BOM listing the same item more than once holds several
|
||||
values per group. Aggregating each independently can pair one line's description with another's
|
||||
warehouse -- or a uom with the wrong conversion_factor -- and Max() over text is a sort, which
|
||||
MariaDB (case-folding) and PostgreSQL (byte order) resolve differently. Take the first by idx.
|
||||
"""
|
||||
repeated = [row for row in rows if (row.pop("line_count", 1) or 1) > 1]
|
||||
if not repeated:
|
||||
return
|
||||
|
||||
columns = _line_columns_for(doctype)
|
||||
representative = _representative_lines(doctype, bom, tuple(keys), tuple(columns))
|
||||
|
||||
for row in repeated:
|
||||
line = representative.get(tuple(row.get(key) for key in keys))
|
||||
if not line:
|
||||
continue
|
||||
for column in columns:
|
||||
row[column] = line.get(column)
|
||||
|
||||
|
||||
@request_cache
|
||||
def _representative_lines(doctype, bom, keys, columns):
|
||||
"""Cached per request: get_bom_items_as_dict recurses through phantom BOMs, and the same
|
||||
sub-BOM is commonly reached more than once."""
|
||||
representative = {}
|
||||
for line in frappe.get_all(
|
||||
doctype,
|
||||
filters={"parent": bom, "parenttype": "BOM", "docstatus": ("<", 2)},
|
||||
fields=[*keys, *columns],
|
||||
order_by="idx",
|
||||
):
|
||||
representative.setdefault(tuple(line.get(key) for key in keys), line)
|
||||
|
||||
return representative
|
||||
return query.groupby(*group_by).orderby(Min(t.bom_item.idx)).run(as_dict=True)
|
||||
|
||||
|
||||
def _get_bom_item_tables(opts):
|
||||
@@ -1323,16 +1264,16 @@ def _build_base_bom_items_query(bom, company, qty, t):
|
||||
def _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods):
|
||||
is_stock_item = cint(not opts.include_non_stock_items)
|
||||
stock_item_condition = t.item_doc.is_stock_item.isin([1, is_stock_item])
|
||||
if opts.fetch_secondary_items:
|
||||
return _add_secondary_item_columns(query, t, stock_item_condition)
|
||||
|
||||
# BOM Item rate is per row UOM, while BOM Explosion Item rate is per stock UOM. Select the
|
||||
# matching quantity so a normal BOM row's conversion factor is not applied twice.
|
||||
qty_col = t.bom_item.stock_qty if cint(opts.fetch_exploded) else t.bom_item.qty
|
||||
amount_col = (Sum(qty_col / IfNull(t.bom_doc.quantity, 1) * t.bom_item.rate) * opts.qty).as_("amount")
|
||||
# rate is constant per grouped item -> Max() keeps it out of the Sum (preserving the original
|
||||
# Sum(...) * rate * qty arithmetic) while making the expression postgres-valid under GROUP BY.
|
||||
amount_col = (
|
||||
Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * Max(t.bom_item.rate) * opts.qty
|
||||
).as_("amount")
|
||||
|
||||
if cint(opts.fetch_exploded):
|
||||
return _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition)
|
||||
if opts.fetch_secondary_items:
|
||||
return _add_secondary_item_columns(query, t, stock_item_condition)
|
||||
return _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_semi_finished_goods)
|
||||
|
||||
|
||||
@@ -1349,11 +1290,10 @@ def _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition):
|
||||
# keeping the GROUP BY postgres-valid; the correlated idx subquery references only item_code
|
||||
# (a grouped column) so it stays valid and still overrides the explosion idx for display.
|
||||
query = query.select(
|
||||
Max(t.bom_item.description).as_("description"),
|
||||
Max(t.bom_item.source_warehouse).as_("source_warehouse"),
|
||||
Count(t.bom_item.name).distinct().as_("line_count"),
|
||||
Max(t.bom_item.operation).as_("operation"),
|
||||
Max(t.bom_item.include_item_in_manufacturing).as_("include_item_in_manufacturing"),
|
||||
Max(t.bom_item.description).as_("description"),
|
||||
Max(t.bom_item.rate).as_("rate"),
|
||||
Max(t.bom_item.sourced_by_supplier).as_("sourced_by_supplier"),
|
||||
amount_col,
|
||||
@@ -1389,15 +1329,14 @@ def _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_s
|
||||
# under the same alias and silently shadowed (last value wins in the dict), so it is dropped here
|
||||
# -- output is unchanged.
|
||||
query = query.select(
|
||||
Max(t.bom_item.description).as_("description"),
|
||||
Max(t.bom_item.uom).as_("uom"),
|
||||
Max(t.bom_item.conversion_factor).as_("conversion_factor"),
|
||||
Max(t.bom_item.source_warehouse).as_("source_warehouse"),
|
||||
Count(t.bom_item.name).distinct().as_("line_count"),
|
||||
Max(t.bom_item.operation).as_("operation"),
|
||||
Max(t.bom_item.include_item_in_manufacturing).as_("include_item_in_manufacturing"),
|
||||
Max(t.bom_item.sourced_by_supplier).as_("sourced_by_supplier"),
|
||||
Max(t.bom_item.uom).as_("uom"),
|
||||
Max(t.bom_item.conversion_factor).as_("conversion_factor"),
|
||||
amount_col,
|
||||
Max(t.bom_item.description).as_("description"),
|
||||
Max(t.bom_item.base_rate).as_("rate"),
|
||||
Max(t.bom_item.operation_row_id).as_("operation_row_id"),
|
||||
t.bom_item.is_phantom_item,
|
||||
|
||||
@@ -101,70 +101,6 @@ class TestBOM(ERPNextTestSuite):
|
||||
self.assertEqual(flt(items_dict[component].qty), 1.0)
|
||||
self.assertNotIn(rm_normal, items_dict)
|
||||
|
||||
@timeout
|
||||
def test_get_items_amount_uses_each_lines_own_rate(self):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
|
||||
rm = make_item(properties={"is_stock_item": 1, "valuation_rate": 10, "stock_uom": "Nos"})
|
||||
if not any(row.uom == "Box" for row in rm.uoms):
|
||||
rm.append("uoms", {"uom": "Box", "conversion_factor": 5})
|
||||
rm.save()
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm.name], rm_qty=2, do_not_save=True)
|
||||
bom.append("items", {"item_code": rm.name, "qty": 3, "uom": "Box", "stock_uom": "Nos"})
|
||||
bom.save()
|
||||
bom.submit()
|
||||
|
||||
lines = [row for row in bom.items if row.item_code == rm.name]
|
||||
self.assertEqual(len(lines), 2)
|
||||
self.assertEqual(len({flt(row.rate) for row in lines}), 2)
|
||||
|
||||
requested_qty = 2
|
||||
expected = sum(flt(row.qty) * flt(row.rate) for row in lines) / flt(bom.quantity) * requested_qty
|
||||
items_dict = get_bom_items_as_dict(bom.name, "_Test Company", qty=requested_qty, fetch_exploded=0)
|
||||
|
||||
self.assertEqual(len([row for row in items_dict if row == rm.name]), 1)
|
||||
self.assertAlmostEqual(flt(items_dict[rm.name].amount), expected, places=2)
|
||||
|
||||
@timeout
|
||||
def test_get_items_takes_line_columns_from_one_line(self):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
rm = make_item(properties={"is_stock_item": 1, "valuation_rate": 10})
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
|
||||
first_warehouse = create_warehouse("_Test BOM Line A")
|
||||
second_warehouse = create_warehouse("_Test BOM Line B")
|
||||
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm.name], rm_qty=2, do_not_save=True)
|
||||
bom.items[0].description = "bbb first line"
|
||||
bom.items[0].source_warehouse = first_warehouse
|
||||
bom.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": rm.name,
|
||||
"qty": 3,
|
||||
"uom": rm.stock_uom,
|
||||
"stock_uom": rm.stock_uom,
|
||||
"description": "ccc second line",
|
||||
"source_warehouse": second_warehouse,
|
||||
},
|
||||
)
|
||||
bom.save()
|
||||
bom.submit()
|
||||
|
||||
items_dict = get_bom_items_as_dict(bom.name, "_Test Company", qty=1, fetch_exploded=0)
|
||||
row = items_dict[rm.name]
|
||||
|
||||
# "ccc" sorts above "bbb" on either engine, so an aggregated description would win here;
|
||||
# the value must instead come from the first line, together with that line's warehouse
|
||||
self.assertEqual(row.description, "bbb first line")
|
||||
self.assertEqual(row.source_warehouse, first_warehouse)
|
||||
|
||||
@timeout
|
||||
def test_default_bom(self):
|
||||
def _get_default_bom_in_item():
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user