mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-05 10:43:04 +00:00
Compare commits
79 Commits
pg-audit/c
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e92d74a2c | ||
|
|
bb29e6c8f4 | ||
|
|
4ec844ac58 | ||
|
|
e5549457e7 | ||
|
|
6d6e70fe38 | ||
|
|
e1d9757ff8 | ||
|
|
ec1d66acf3 | ||
|
|
2684fd89d0 | ||
|
|
1c17da8963 | ||
|
|
63e20588f6 | ||
|
|
c389ea9b82 | ||
|
|
4abf3b5baf | ||
|
|
d6c9ffc900 | ||
|
|
6d9dc42337 | ||
|
|
1d2b6966ec | ||
|
|
b707c595d1 | ||
|
|
13b355fb97 | ||
|
|
08ca615278 | ||
|
|
04f021aff6 | ||
|
|
fc64904f8c | ||
|
|
d0ed120c9b | ||
|
|
f43c9d4dd5 | ||
|
|
ed80c61ed7 | ||
|
|
e9409f68ef | ||
|
|
d941e89028 | ||
|
|
4350a271d3 | ||
|
|
f5867ce6a9 | ||
|
|
2684973536 | ||
|
|
9f2af3fcd3 | ||
|
|
27cc2863db | ||
|
|
1ea9cf9fb2 | ||
|
|
3b913df057 | ||
|
|
0be3ca334d | ||
|
|
aa886c0e02 | ||
|
|
050948c032 | ||
|
|
33fafa444a | ||
|
|
2ca1113f79 | ||
|
|
cfb85285aa | ||
|
|
7944ab0557 | ||
|
|
abc3da6b97 | ||
|
|
915eef0355 | ||
|
|
947f1b148c | ||
|
|
732c884633 | ||
|
|
a3e9d13da3 | ||
|
|
b4d73cd934 | ||
|
|
99630f40eb | ||
|
|
0b271e24b6 | ||
|
|
248873034d | ||
|
|
446ec6030a | ||
|
|
c020de5a69 | ||
|
|
f03c1311cd | ||
|
|
8154c45bf0 | ||
|
|
00d17ca5db | ||
|
|
5b5f354090 | ||
|
|
b1f188146e | ||
|
|
3752be809f | ||
|
|
e74c0a3cdb | ||
|
|
d74add35d4 | ||
|
|
bf869c3426 | ||
|
|
d44ed5357d | ||
|
|
1968f06cc8 | ||
|
|
a7a14c82da | ||
|
|
282712eec2 | ||
|
|
c8adf9937b | ||
|
|
100d0ee784 | ||
|
|
414e6560af | ||
|
|
a30f3dde0f | ||
|
|
03183fc4d9 | ||
|
|
d5ea0d1f6f | ||
|
|
5eabd176f5 | ||
|
|
8f227ad80e | ||
|
|
2c6208ad00 | ||
|
|
80ca8b3a25 | ||
|
|
39b6f37a48 | ||
|
|
28d498012a | ||
|
|
ccf54b5881 | ||
|
|
1a83fc516e | ||
|
|
cde2963da1 | ||
|
|
b63066ed44 |
7
.github/POSTGRES_COMPATIBILITY.md
vendored
7
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -170,6 +170,13 @@ 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,7 +171,30 @@ jobs:
|
||||
update_to_version 16 3.14
|
||||
|
||||
echo "Updating to latest version"
|
||||
git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
|
||||
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" checkout -q -f FETCH_HEAD
|
||||
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
|
||||
|
||||
|
||||
@@ -23,3 +23,5 @@ 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
@@ -19,13 +19,22 @@ 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("")
|
||||
|
||||
// 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 { data: companies, error } = useFrappeGetDocList("Company", {
|
||||
limit: 0,
|
||||
fields: ["name"],
|
||||
}, 'company_list', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const options = companies?.map((company: { name: string }) => company.name) || []
|
||||
|
||||
const setSelectedCompany = useSetAtom(selectedCompanyAtom)
|
||||
const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom)
|
||||
@@ -42,6 +51,10 @@ const CompanySelector = ({ onChange }: { onChange?: (company: string) => void })
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
}
|
||||
|
||||
return (<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '')
|
||||
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '', undefined, {
|
||||
getOnInit: true,
|
||||
})
|
||||
|
||||
export const useCurrentCompany = () => {
|
||||
const selectedCompany = useAtomValue(selectedCompanyAtom)
|
||||
|
||||
@@ -187,6 +187,103 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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, [])
|
||||
invoice_tax_map[d.parent][d.account_head] = flt(d.tax_amount)
|
||||
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)
|
||||
|
||||
return invoice_expense_map, invoice_tax_map
|
||||
|
||||
|
||||
@@ -47,6 +47,41 @@ 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, Sum
|
||||
from frappe.query_builder.functions import Coalesce, Max, Min, Sum
|
||||
from frappe.utils import cstr
|
||||
|
||||
|
||||
@@ -128,25 +128,47 @@ 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/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 = (
|
||||
# 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"),
|
||||
Max(sii.warehouse).as_("warehouse"),
|
||||
Max(sii.cost_center).as_("cost_center"),
|
||||
)
|
||||
.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 = (
|
||||
frappe.qb.from_(grouped_items)
|
||||
.inner_join(representative_item)
|
||||
.on(
|
||||
(representative_item.parent == grouped_items.parent)
|
||||
& (representative_item.idx == grouped_items.representative_idx)
|
||||
)
|
||||
.select(
|
||||
grouped_items.parent,
|
||||
grouped_items.base_total,
|
||||
representative_item.warehouse,
|
||||
representative_item.cost_center,
|
||||
)
|
||||
)
|
||||
|
||||
# t3: mode_of_payment per invoice (arbitrary across an invoice's payment lines -> Max() to be valid)
|
||||
# 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 = (
|
||||
frappe.qb.from_(sip)
|
||||
.select(sip.parent, Max(sip.mode_of_payment).as_("mode_of_payment"))
|
||||
.groupby(sip.parent)
|
||||
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"))
|
||||
)
|
||||
|
||||
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns
|
||||
|
||||
@@ -54,6 +54,53 @@ 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()
|
||||
|
||||
|
||||
@@ -2379,13 +2379,16 @@ class QueryPaymentLedger:
|
||||
)
|
||||
|
||||
# build query for voucher amount
|
||||
query_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 = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
# 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.account,
|
||||
ple.voucher_type,
|
||||
ple.voucher_no,
|
||||
ple.party_type,
|
||||
@@ -2393,25 +2396,47 @@ 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"),
|
||||
Max(ple.remarks).as_("remarks"),
|
||||
Min(ple.name).as_("representative"),
|
||||
)
|
||||
.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.voucher_type, ple.voucher_no, ple.party_type, ple.party)
|
||||
.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"),
|
||||
)
|
||||
)
|
||||
|
||||
# build query for voucher outstanding
|
||||
query_voucher_outstanding = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
# Max() on columns constant per group keeps this valid on postgres (see above)
|
||||
Max(ple.account).as_("account"),
|
||||
# grouped, not aggregated: this is the other side of the join key -- see above
|
||||
ple.account,
|
||||
ple.against_voucher_type.as_("voucher_type"),
|
||||
ple.against_voucher_no.as_("voucher_no"),
|
||||
ple.party_type,
|
||||
@@ -2425,7 +2450,7 @@ class QueryPaymentLedger:
|
||||
.where(ple.delinked == 0)
|
||||
.where(Criterion.all(filter_on_against_voucher_no))
|
||||
.where(Criterion.all(self.common_filter))
|
||||
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
|
||||
.groupby(ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
|
||||
)
|
||||
|
||||
# build CTE for combining voucher amount and outstanding
|
||||
|
||||
@@ -216,6 +216,21 @@ 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)
|
||||
@@ -1044,6 +1059,8 @@ 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 (
|
||||
@@ -1055,9 +1072,6 @@ 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"
|
||||
|
||||
@@ -1496,6 +1510,7 @@ 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",
|
||||
|
||||
@@ -51,7 +51,6 @@ 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"),
|
||||
@@ -60,8 +59,6 @@ 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(
|
||||
@@ -75,8 +72,34 @@ 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))
|
||||
data = query.run(as_dict=True)
|
||||
return data
|
||||
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 ""
|
||||
|
||||
|
||||
def get_conditions(filters, query, mr, mr_item):
|
||||
|
||||
@@ -73,14 +73,17 @@ def employee_query(
|
||||
.where(Criterion.any(search_conditions))
|
||||
.orderby(
|
||||
Case()
|
||||
.when(Locate(txt_no_percent, Employee.name) > 0, Locate(txt_no_percent, Employee.name))
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(Employee.name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(Employee.name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(
|
||||
Case()
|
||||
.when(
|
||||
Locate(txt_no_percent, Employee.employee_name) > 0,
|
||||
Locate(txt_no_percent, Employee.employee_name),
|
||||
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
@@ -136,17 +139,28 @@ 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(txt_no_percent, Lead.lead_name) > 0, Locate(txt_no_percent, Lead.lead_name))
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(Lead.name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(Lead.name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(
|
||||
Case()
|
||||
.when(Locate(txt_no_percent, Lead.company_name) > 0, Locate(txt_no_percent, Lead.company_name))
|
||||
.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)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(Lead.idx, order=Order.desc)
|
||||
@@ -387,7 +401,12 @@ def bom(
|
||||
.where(BOM.is_active == 1)
|
||||
.where(BOM[searchfield].like(f"%{txt}%"))
|
||||
.orderby(
|
||||
Case().when(Locate(txt_no_percent, BOM.name) > 0, Locate(txt_no_percent, BOM.name)).else_(99999)
|
||||
Case()
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(BOM.name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(BOM.name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(BOM.idx, order=Order.desc)
|
||||
.orderby(BOM.name)
|
||||
|
||||
@@ -160,10 +160,28 @@ def validate_returned_items(doc):
|
||||
):
|
||||
frappe.throw(_("Warehouse is mandatory"))
|
||||
|
||||
items_returned = True
|
||||
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
|
||||
|
||||
elif d.item_name:
|
||||
items_returned = True
|
||||
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
|
||||
|
||||
if not items_returned:
|
||||
frappe.throw(_("At least one item should be entered with negative quantity in return document"))
|
||||
|
||||
@@ -446,11 +446,12 @@ class StatusUpdater(Document):
|
||||
else (0, {}, None, None)
|
||||
)
|
||||
|
||||
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
|
||||
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")
|
||||
|
||||
overflow_percent = (
|
||||
(item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]]
|
||||
|
||||
@@ -29,6 +29,27 @@ 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,3 +37,76 @@ 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,6 +376,41 @@ 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 = {}
|
||||
|
||||
@@ -385,12 +420,14 @@ 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 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"] = ""
|
||||
# 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"
|
||||
|
||||
elif based_on == "Item Group":
|
||||
based_on_details["based_on_cols"] = [
|
||||
@@ -425,9 +462,17 @@ def based_wise_columns_query(based_on, trans):
|
||||
"fieldname": "territory",
|
||||
},
|
||||
]
|
||||
based_on_details[
|
||||
"based_on_select"
|
||||
] = "t1.party_name, Max(t1.customer_name) as customer_name, Max(t1.territory) as 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"] = ""
|
||||
else:
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
@@ -451,13 +496,19 @@ 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_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"] = ""
|
||||
"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"
|
||||
|
||||
elif based_on == "Customer Group":
|
||||
based_on_details["based_on_cols"] = [
|
||||
@@ -490,14 +541,12 @@ def based_wise_columns_query(based_on, trans):
|
||||
"fieldname": "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"
|
||||
# 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"
|
||||
based_on_details["addl_tables"] = ",`tabSupplier` t3"
|
||||
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
|
||||
|
||||
|
||||
3358
erpnext/locale/ar.po
3358
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/bg.po
3342
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
3468
erpnext/locale/bs.po
3468
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/cs.po
3342
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
3376
erpnext/locale/da.po
3376
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3366
erpnext/locale/de.po
3366
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3376
erpnext/locale/eo.po
3376
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3366
erpnext/locale/es.po
3366
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
3470
erpnext/locale/fa.po
3470
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
3356
erpnext/locale/fr.po
3356
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
3354
erpnext/locale/hi.po
3354
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3448
erpnext/locale/hr.po
3448
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/hu.po
3342
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
3348
erpnext/locale/id.po
3348
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
3358
erpnext/locale/it.po
3358
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
3350
erpnext/locale/ko.po
3350
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/my.po
3342
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/nb.po
3342
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
3366
erpnext/locale/nl.po
3366
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
3352
erpnext/locale/pl.po
3352
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/pt.po
3342
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
64457
erpnext/locale/ro.po
Normal file
64457
erpnext/locale/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
3370
erpnext/locale/ru.po
3370
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
3598
erpnext/locale/sl.po
3598
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
3366
erpnext/locale/sr.po
3366
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3380
erpnext/locale/sv.po
3380
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
3366
erpnext/locale/th.po
3366
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
3360
erpnext/locale/tr.po
3360
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
3372
erpnext/locale/uz.po
3372
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
3366
erpnext/locale/vi.po
3366
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
19759
erpnext/locale/zh.po
19759
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,32 @@ 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)
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -1208,7 +1209,65 @@ 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
|
||||
return query.groupby(*group_by).orderby(Min(t.bom_item.idx)).run(as_dict=True)
|
||||
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
|
||||
|
||||
|
||||
def _get_bom_item_tables(opts):
|
||||
@@ -1264,16 +1323,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])
|
||||
# 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 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")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -1290,10 +1349,11 @@ 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,
|
||||
@@ -1329,14 +1389,15 @@ 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.uom).as_("uom"),
|
||||
Max(t.bom_item.conversion_factor).as_("conversion_factor"),
|
||||
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.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,6 +101,70 @@ 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():
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"""BOM explosion helpers for Production Plan material planning."""
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import IfNull, Max, Min, Sum
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Min, Sum
|
||||
from frappe.utils.caching import request_cache
|
||||
|
||||
from erpnext.manufacturing.doctype.production_plan.services.planning_queries import get_uom_conversion_factor
|
||||
|
||||
@@ -21,7 +22,7 @@ def _exploded_items_query(company, bom_no, include_non_stock_items, planned_qty)
|
||||
item = frappe.qb.DocType("Item")
|
||||
item_default = frappe.qb.DocType("Item Default")
|
||||
item_uom = frappe.qb.DocType("UOM Conversion Detail")
|
||||
return (
|
||||
rows = (
|
||||
frappe.qb.from_(bei)
|
||||
.join(bom)
|
||||
.on(bom.name == bei.parent)
|
||||
@@ -36,19 +37,92 @@ def _exploded_items_query(company, bom_no, include_non_stock_items, planned_qty)
|
||||
.groupby(bei.item_code, bei.stock_uom)
|
||||
).run(as_dict=True)
|
||||
|
||||
_apply_representative_lines(
|
||||
rows, "BOM Explosion Item", bom_no, ("item_code", "stock_uom"), include_non_stock_items
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _apply_representative_lines(rows, doctype, bom_no, keys, include_non_stock_items=True):
|
||||
"""Fill description/source_warehouse from a single real BOM line per group.
|
||||
|
||||
Both 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, and Max() over text is a sort -- MariaDB folds case, PostgreSQL
|
||||
orders by byte value, so the two engines pick differently. Take the first line by idx.
|
||||
|
||||
Only groups built from more than one line need this. Where a group has a single line, Max() of
|
||||
one value is that value, so the selected columns are already exact and no query is issued --
|
||||
which matters because this runs once per BOM in a recursive explosion.
|
||||
"""
|
||||
repeated = [row for row in rows if (row.pop("line_count", 1) or 1) > 1]
|
||||
if not repeated:
|
||||
return
|
||||
|
||||
representative = _representative_lines(doctype, bom_no, tuple(keys), include_non_stock_items)
|
||||
|
||||
for row in repeated:
|
||||
line = representative.get(tuple(row.get(key) for key in keys))
|
||||
if line:
|
||||
row.description = line.description
|
||||
row.source_warehouse = line.source_warehouse
|
||||
|
||||
|
||||
@request_cache
|
||||
def _representative_lines(doctype, bom_no, keys, include_non_stock_items):
|
||||
"""Cached per request: the explosion recurses and commonly revisits the same sub-BOM."""
|
||||
# only BOM Item carries is_phantom_item, and only its query ORs the phantom flag into the stock
|
||||
# filter; the explosion table has neither
|
||||
filters_phantom = doctype == "BOM Item"
|
||||
fields = ["item_code", "stock_uom", "description", "source_warehouse"]
|
||||
if filters_phantom:
|
||||
fields.append("is_phantom_item")
|
||||
|
||||
lines = frappe.get_all(
|
||||
doctype,
|
||||
filters={
|
||||
"parent": bom_no,
|
||||
"parenttype": "BOM",
|
||||
"is_sub_assembly_item": 0,
|
||||
"docstatus": ("<", 2),
|
||||
},
|
||||
fields=fields,
|
||||
order_by="idx",
|
||||
)
|
||||
|
||||
# mirror the caller's stock filter: a non-stock line the main query excluded must not become
|
||||
# the representative for a group that only exists because of a phantom line
|
||||
if not include_non_stock_items and filters_phantom and lines:
|
||||
stock_items = set(
|
||||
frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("in", list({line.item_code for line in lines})), "is_stock_item": 1},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
lines = [line for line in lines if line.item_code in stock_items or line.is_phantom_item]
|
||||
|
||||
representative = {}
|
||||
for line in lines:
|
||||
representative.setdefault(tuple(line.get(key) for key in keys), line)
|
||||
|
||||
return representative
|
||||
|
||||
|
||||
def _exploded_item_columns(bei, bom, item, item_default, item_uom, planned_qty):
|
||||
# only item_code/stock_uom are grouped; the rest are functionally dependent on the grouped item
|
||||
# or arbitrary per BOM Item on MySQL -> Max() keeps the GROUP BY valid on postgres with the same
|
||||
# value MySQL picked.
|
||||
# every column here is functionally dependent on the grouped item_code -- Item, Item Default and
|
||||
# UOM Conversion Detail are joined on it and the BOM is pinned by the filter -- so Max() returns
|
||||
# their single value. The BOM-line columns come from a representative line instead; see
|
||||
# _apply_representative_lines.
|
||||
return [
|
||||
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
|
||||
Max(item.item_name).as_("item_name"),
|
||||
Max(item.name).as_("item_code"),
|
||||
Max(bei.description).as_("description"),
|
||||
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||
Count(bei.name).distinct().as_("line_count"),
|
||||
bei.stock_uom,
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||
Max(item.default_material_request_type).as_("default_material_request_type"),
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(item_default.default_warehouse).as_("default_warehouse"),
|
||||
@@ -96,7 +170,7 @@ def _subitems_query(company, bom_no, include_non_stock_items, parent_qty, planne
|
||||
item = frappe.qb.DocType("Item")
|
||||
item_default = frappe.qb.DocType("Item Default")
|
||||
item_uom = frappe.qb.DocType("UOM Conversion Detail")
|
||||
return (
|
||||
rows = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.join(bom)
|
||||
.on(bom.name == bom_item.parent)
|
||||
@@ -113,6 +187,9 @@ def _subitems_query(company, bom_no, include_non_stock_items, parent_qty, planne
|
||||
.orderby(Min(bom_item.idx))
|
||||
).run(as_dict=True)
|
||||
|
||||
_apply_representative_lines(rows, "BOM Item", bom_no, ("item_code",), include_non_stock_items)
|
||||
return rows
|
||||
|
||||
|
||||
def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty):
|
||||
qty = IfNull(parent_qty * Sum(bom_item.stock_qty / IfNull(bom.quantity, 1)) * planned_qty, 0).as_("qty")
|
||||
@@ -128,9 +205,10 @@ def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, pl
|
||||
Max(item.item_name).as_("item_name"),
|
||||
qty,
|
||||
Max(item.is_sub_contracted_item).as_("is_sub_contracted"),
|
||||
Max(bom_item.source_warehouse).as_("source_warehouse"),
|
||||
Max(item.default_bom).as_("default_bom"),
|
||||
Max(bom_item.description).as_("description"),
|
||||
Max(bom_item.source_warehouse).as_("source_warehouse"),
|
||||
Count(bom_item.name).distinct().as_("line_count"),
|
||||
Max(item.default_bom).as_("default_bom"),
|
||||
Max(bom_item.stock_uom).as_("stock_uom"),
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(item.safety_stock).as_("safety_stock"),
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
"""Sub-assembly resolution helpers for Production Plan."""
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import IfNull, Max, Sum
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Sum
|
||||
from frappe.utils import flt
|
||||
from frappe.utils.caching import request_cache
|
||||
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
|
||||
from erpnext.manufacturing.doctype.production_plan.services.planning_queries import (
|
||||
@@ -167,7 +168,7 @@ def _sub_assembly_rm_query(company, bom_no, include_non_stock_items, planned_qty
|
||||
item = frappe.qb.DocType("Item")
|
||||
item_default = frappe.qb.DocType("Item Default")
|
||||
item_uom = frappe.qb.DocType("UOM Conversion Detail")
|
||||
return (
|
||||
rows = (
|
||||
frappe.qb.from_(bei)
|
||||
.join(bom)
|
||||
.on(bom.name == bei.parent)
|
||||
@@ -182,6 +183,50 @@ def _sub_assembly_rm_query(company, bom_no, include_non_stock_items, planned_qty
|
||||
.groupby(bei.item_code, bei.stock_uom, bei.bom_no, bei.is_phantom_item)
|
||||
).run(as_dict=True)
|
||||
|
||||
_apply_representative_lines(rows, bom_no)
|
||||
return rows
|
||||
|
||||
|
||||
def _apply_representative_lines(rows, bom_no):
|
||||
"""Fill description/source_warehouse from a single real BOM Item line per group.
|
||||
|
||||
Both describe a line, not an item. Aggregating each independently can pair one line's
|
||||
description with another's warehouse, and Max() over text is a sort -- MariaDB folds case,
|
||||
PostgreSQL orders by byte value, so the engines pick differently. Take the first line by idx.
|
||||
"""
|
||||
repeated = [row for row in rows if (row.pop("line_count", 1) or 1) > 1]
|
||||
if not repeated:
|
||||
return
|
||||
|
||||
keys = ("item_code", "stock_uom", "bom_no", "is_phantom_item")
|
||||
representative = _representative_lines(bom_no, keys)
|
||||
|
||||
for row in repeated:
|
||||
line = representative.get(tuple(row.get(key) for key in keys))
|
||||
if line:
|
||||
row.description = line.description
|
||||
row.source_warehouse = line.source_warehouse
|
||||
|
||||
|
||||
@request_cache
|
||||
def _representative_lines(bom_no, keys):
|
||||
"""Cached per request: sub-assembly resolution recurses and revisits the same BOM."""
|
||||
representative = {}
|
||||
for line in frappe.get_all(
|
||||
"BOM Item",
|
||||
filters={
|
||||
"parent": bom_no,
|
||||
"parenttype": "BOM",
|
||||
"is_sub_assembly_item": 0,
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=["item_code", "stock_uom", "bom_no", "is_phantom_item", "description", "source_warehouse"],
|
||||
order_by="idx",
|
||||
):
|
||||
representative.setdefault(tuple(line.get(key) for key in keys), line)
|
||||
|
||||
return representative
|
||||
|
||||
|
||||
def _sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty):
|
||||
# Grouped by item_code/stock_uom plus bom_no/is_phantom_item: those two MUST come from the same
|
||||
@@ -195,11 +240,12 @@ def _sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty
|
||||
Max(item.item_name).as_("item_name"),
|
||||
Max(item.name).as_("item_code"),
|
||||
Max(bei.description).as_("description"),
|
||||
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||
Count(bei.name).distinct().as_("line_count"),
|
||||
bei.stock_uom,
|
||||
bei.is_phantom_item,
|
||||
bei.bom_no,
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||
Max(item.default_material_request_type).as_("default_material_request_type"),
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(item_default.default_warehouse).as_("default_warehouse"),
|
||||
|
||||
@@ -2889,6 +2889,99 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
f"BOM-path disassembly must apply process_loss_per; expected 18, got {bom_scrap_row.qty}",
|
||||
)
|
||||
|
||||
def test_disassembly_mixed_uom_rows_are_aggregated_in_stock_uom(self):
|
||||
"""Quantities and rates from different row UOMs must be aggregated in stock UOM."""
|
||||
from erpnext.stock.doctype.stock_entry.services.disassemble import DisassembleStockEntry
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
raw_item_doc = make_item(
|
||||
"Test Raw for Disassembly Coherence", {"is_stock_item": 1, "stock_uom": "Nos"}
|
||||
)
|
||||
box_uom = next((row for row in raw_item_doc.uoms if row.uom == "Box"), None)
|
||||
if box_uom:
|
||||
box_uom.conversion_factor = 5
|
||||
else:
|
||||
raw_item_doc.append("uoms", {"uom": "Box", "conversion_factor": 5})
|
||||
raw_item_doc.save()
|
||||
raw_item = raw_item_doc.name
|
||||
fg_item = make_item("Test FG for Disassembly Coherence", {"is_stock_item": 1}).name
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)
|
||||
|
||||
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
|
||||
make_stock_entry_test_record(
|
||||
item_code=raw_item,
|
||||
purpose="Material Receipt",
|
||||
target=wo.wip_warehouse,
|
||||
qty=50,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
|
||||
for item in transfer.items:
|
||||
item.s_warehouse = wo.wip_warehouse
|
||||
transfer.save()
|
||||
transfer.submit()
|
||||
|
||||
first = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 5))
|
||||
first.submit()
|
||||
second = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 5))
|
||||
second.submit()
|
||||
wo.reload()
|
||||
|
||||
first_row = next(row for row in first.items if row.item_code == raw_item)
|
||||
second_row = next(row for row in second.items if row.item_code == raw_item)
|
||||
first_stock_qty = flt(first_row.transfer_qty)
|
||||
frappe.db.set_value(
|
||||
"Stock Entry Detail",
|
||||
first_row.name,
|
||||
{
|
||||
"uom": "Box",
|
||||
"conversion_factor": 5,
|
||||
"qty": first_stock_qty / 5,
|
||||
"transfer_qty": first_stock_qty,
|
||||
"basic_rate": 100,
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
frappe.db.set_value("Stock Entry Detail", second_row.name, "basic_rate", 200, update_modified=False)
|
||||
|
||||
posted_rows = frappe.get_all(
|
||||
"Stock Entry Detail",
|
||||
filters={"parent": ("in", [first.name, second.name]), "item_code": raw_item},
|
||||
fields=["qty", "transfer_qty", "uom", "conversion_factor", "basic_rate"],
|
||||
)
|
||||
self.assertEqual(len({row.uom for row in posted_rows}), 2)
|
||||
self.assertTrue(
|
||||
all(flt(row.qty) * flt(row.conversion_factor) == flt(row.transfer_qty) for row in posted_rows)
|
||||
)
|
||||
|
||||
service = DisassembleStockEntry(frappe._dict(work_order=wo.name, source_stock_entry=None))
|
||||
source_row = next(
|
||||
row for row in service.get_items_from_manufacture_stock_entry() if row.item_code == raw_item
|
||||
)
|
||||
|
||||
expected_stock_qty = sum(flt(row.transfer_qty) for row in posted_rows)
|
||||
expected_rate = (
|
||||
sum(flt(row.basic_rate) * flt(row.transfer_qty) for row in posted_rows) / expected_stock_qty
|
||||
)
|
||||
self.assertEqual(source_row.uom, source_row.stock_uom)
|
||||
self.assertEqual(flt(source_row.conversion_factor), 1.0)
|
||||
self.assertEqual(flt(source_row.qty), expected_stock_qty)
|
||||
self.assertEqual(flt(source_row.transfer_qty), expected_stock_qty)
|
||||
self.assertAlmostEqual(flt(source_row.basic_rate), expected_rate, places=6)
|
||||
|
||||
disassemble_qty = 4
|
||||
disassembly = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
|
||||
disassembly.save()
|
||||
disassembly_row = next(row for row in disassembly.items if row.item_code == raw_item)
|
||||
expected_disassembly_qty = expected_stock_qty * disassemble_qty / flt(wo.produced_qty)
|
||||
self.assertEqual(disassembly_row.uom, disassembly_row.stock_uom)
|
||||
self.assertEqual(flt(disassembly_row.conversion_factor), 1.0)
|
||||
self.assertEqual(flt(disassembly_row.transfer_qty), expected_disassembly_qty)
|
||||
disassembly.submit()
|
||||
|
||||
def test_disassembly_with_additional_rm_not_in_bom(self):
|
||||
"""
|
||||
Test that SE-linked disassembly includes additional raw materials
|
||||
|
||||
@@ -134,15 +134,15 @@ def get_data_without_qty_to_make(filters):
|
||||
for row in raw_rows:
|
||||
data.append(
|
||||
{
|
||||
"item": row[0],
|
||||
"description": row[1],
|
||||
"from_bom_no": row[2],
|
||||
"qty_per_unit": fmt_qty(row[3]),
|
||||
"available_qty": fmt_qty(row[4]),
|
||||
"item": row.item_code,
|
||||
"description": row.description,
|
||||
"from_bom_no": row.from_bom_no,
|
||||
"qty_per_unit": fmt_qty(row.qty_per_unit),
|
||||
"available_qty": fmt_qty(row.available_qty),
|
||||
}
|
||||
)
|
||||
|
||||
min_producible = min((row[5] or 0) for row in raw_rows) if raw_rows else 0
|
||||
min_producible = min((row.producible_qty or 0) for row in raw_rows) if raw_rows else 0
|
||||
# blank spacer row
|
||||
data.append({})
|
||||
|
||||
@@ -190,27 +190,14 @@ def batch_fetch_purchase_rates(bom_data):
|
||||
}
|
||||
|
||||
|
||||
def get_bom_data(filters):
|
||||
bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item"
|
||||
|
||||
bom_item = frappe.qb.DocType(bom_item_table)
|
||||
def get_stock_qty_by_item(filters):
|
||||
"""One row per item_code, so joining it to BOM Item cannot multiply either side's sum."""
|
||||
bin = frappe.qb.DocType("Bin")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.left_join(bin)
|
||||
.on(bom_item.item_code == bin.item_code)
|
||||
.select(
|
||||
bom_item.item_code,
|
||||
# non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid
|
||||
Max(bom_item.description).as_("description"),
|
||||
Max(bom_item.parent).as_("from_bom_no"),
|
||||
Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"),
|
||||
IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
|
||||
)
|
||||
.where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
|
||||
.groupby(bom_item.item_code)
|
||||
.orderby(Min(bom_item.idx))
|
||||
frappe.qb.from_(bin)
|
||||
.select(bin.item_code, Sum(bin.actual_qty).as_("actual_qty"))
|
||||
.groupby(bin.item_code)
|
||||
)
|
||||
|
||||
if filters.get("warehouse"):
|
||||
@@ -233,30 +220,64 @@ def get_bom_data(filters):
|
||||
else:
|
||||
query = query.where(bin.warehouse == filters.get("warehouse"))
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def get_bom_data(filters):
|
||||
bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item"
|
||||
|
||||
bom_item = frappe.qb.DocType(bom_item_table)
|
||||
stock_qty = get_stock_qty_by_item(filters).as_("stock_qty")
|
||||
|
||||
base = frappe.qb.from_(bom_item)
|
||||
base = base.join(stock_qty) if filters.get("warehouse") else base.left_join(stock_qty)
|
||||
|
||||
query = (
|
||||
base.on(bom_item.item_code == stock_qty.item_code)
|
||||
.select(
|
||||
bom_item.item_code,
|
||||
# non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid
|
||||
Max(bom_item.parent).as_("from_bom_no"),
|
||||
Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"),
|
||||
IfNull(Max(stock_qty.actual_qty), 0).as_("actual_qty"),
|
||||
)
|
||||
.where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
|
||||
.groupby(bom_item.item_code)
|
||||
.orderby(Min(bom_item.idx))
|
||||
)
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
# description belongs to a BOM line, not to the item, so a component listed more than once holds
|
||||
# several values per group. Max() over text is a sort and the engines sort text differently
|
||||
# (MariaDB folds case, PostgreSQL orders by byte value), so read it off one real line instead.
|
||||
# For BOM Item that same line also supplies bom_no + is_phantom_item, which drive whether and
|
||||
# which sub-BOM explode_phantom_boms recurses into and so must stay coherent with each other:
|
||||
# the first line, upgraded to the first phantom line if any exists, so a phantom sub-BOM is never
|
||||
# dropped just because a non-phantom line happens to be listed first.
|
||||
fields = ["item_code", "description"]
|
||||
if bom_item_table == "BOM Item":
|
||||
fields += ["bom_no", "is_phantom_item"]
|
||||
|
||||
representative = {}
|
||||
for line in frappe.get_all(
|
||||
bom_item_table,
|
||||
filters={"parent": filters.get("bom"), "parenttype": "BOM"},
|
||||
fields=fields,
|
||||
order_by="idx",
|
||||
):
|
||||
existing = representative.get(line.item_code)
|
||||
if existing is None or (line.get("is_phantom_item") and not existing.get("is_phantom_item")):
|
||||
representative[line.item_code] = line
|
||||
|
||||
for row in data:
|
||||
line = representative.get(row.item_code)
|
||||
row.description = line.description if line else None
|
||||
if bom_item_table == "BOM Item":
|
||||
row.bom_no = line.bom_no if line else None
|
||||
row.is_phantom_item = line.is_phantom_item if line else None
|
||||
|
||||
if bom_item_table == "BOM Item":
|
||||
# bom_no + is_phantom_item drive whether/which sub-BOM explode_phantom_boms recurses into, so
|
||||
# they must come from the SAME BOM Item line. Aggregating each independently (Max) could pair a
|
||||
# bom_no from one line with is_phantom_item from another when an item_code repeats in the BOM.
|
||||
# Rows are grouped by item_code (one qty_per_unit total per component), so pick one coherent
|
||||
# representative line: the first line, but upgrade to the first phantom line if any exists, so a
|
||||
# phantom sub-BOM is never dropped just because a non-phantom line happens to be listed first.
|
||||
representative = {}
|
||||
for line in frappe.get_all(
|
||||
"BOM Item",
|
||||
filters={"parent": filters.get("bom"), "parenttype": "BOM"},
|
||||
fields=["item_code", "bom_no", "is_phantom_item"],
|
||||
order_by="idx",
|
||||
):
|
||||
existing = representative.get(line.item_code)
|
||||
if existing is None or (line.is_phantom_item and not existing.is_phantom_item):
|
||||
representative[line.item_code] = line
|
||||
for row in data:
|
||||
line = representative.get(row.item_code)
|
||||
if line:
|
||||
row.bom_no = line.bom_no
|
||||
row.is_phantom_item = line.is_phantom_item
|
||||
return explode_phantom_boms(data, filters)
|
||||
|
||||
return data
|
||||
@@ -337,15 +358,37 @@ def get_producible_fg_items(filters):
|
||||
BOM_ITEM.item_code,
|
||||
# Sum() below makes this an aggregate query; the other columns are constant per grouped
|
||||
# item_code -> Max() keeps them valid on postgres with the same value MySQL picked.
|
||||
Max(BOM_ITEM.description).as_("description"),
|
||||
# description is not: it belongs to the line, so it comes from a representative one below.
|
||||
Max(BOM_ITEM.parent).as_("from_bom_no"),
|
||||
Max(BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"),
|
||||
Max(IfNull(bin_subquery.actual_qty, 0)).as_("available_qty"),
|
||||
Floor(Max(bin_subquery.actual_qty) / ((Sum(BOM_ITEM.stock_qty)) / Max(BOM.quantity))),
|
||||
Floor(Max(bin_subquery.actual_qty) / ((Sum(BOM_ITEM.stock_qty)) / Max(BOM.quantity))).as_(
|
||||
"producible_qty"
|
||||
),
|
||||
)
|
||||
.where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
|
||||
.groupby(BOM_ITEM.item_code)
|
||||
.orderby(Min(BOM_ITEM.idx))
|
||||
)
|
||||
|
||||
return query.run(as_list=True)
|
||||
rows = query.run(as_dict=True)
|
||||
descriptions = get_representative_descriptions("BOM Item", filters.get("bom"))
|
||||
for row in rows:
|
||||
row.description = descriptions.get(row.item_code)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def get_representative_descriptions(doctype, bom):
|
||||
"""First line by idx per item_code. description belongs to a line, not an item, so aggregating it
|
||||
sorts text -- and MariaDB folds case while PostgreSQL orders by byte value."""
|
||||
descriptions = {}
|
||||
for line in frappe.get_all(
|
||||
doctype,
|
||||
filters={"parent": bom, "parenttype": "BOM"},
|
||||
fields=["item_code", "description"],
|
||||
order_by="idx",
|
||||
):
|
||||
descriptions.setdefault(line.item_code, line.description)
|
||||
|
||||
return descriptions
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
import frappe
|
||||
from frappe.utils import fmt_money
|
||||
from frappe.utils import flt, fmt_money
|
||||
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.report.bom_stock_analysis.bom_stock_analysis import (
|
||||
execute as bom_stock_analysis_report,
|
||||
)
|
||||
from erpnext.manufacturing.report.bom_stock_analysis.bom_stock_analysis import get_bom_data
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -146,6 +151,41 @@ class TestBOMStockAnalysis(ERPNextTestSuite):
|
||||
"""
|
||||
self._assert_phantom_exploded(*self._build_duplicate_component_bom(phantom_first=False))
|
||||
|
||||
def test_bom_data_is_not_multiplied_by_the_bin_join(self):
|
||||
"""Bin joins one row per warehouse, BOM Item one per line -- neither sum may count the other.
|
||||
|
||||
With the component listed on two BOM lines and stocked in two warehouses, the join yields
|
||||
four rows. Summing qty_consumed_per_unit over it counts each line once per warehouse, and
|
||||
summing actual_qty counts each warehouse once per line.
|
||||
"""
|
||||
rm = make_item(properties={"is_stock_item": 1, "valuation_rate": 10})
|
||||
fg = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
|
||||
bom = make_bom(item=fg, raw_materials=[rm.name], rm_qty=2, do_not_save=True)
|
||||
bom.append(
|
||||
"items",
|
||||
{"item_code": rm.name, "qty": 3, "uom": rm.stock_uom, "stock_uom": rm.stock_uom},
|
||||
)
|
||||
bom.save()
|
||||
bom.submit()
|
||||
|
||||
for suffix, qty in (("A", 6), ("B", 4)):
|
||||
warehouse = create_warehouse(f"_Test BOM Stock Analysis {suffix}")
|
||||
create_stock_reconciliation(item_code=rm.name, warehouse=warehouse, qty=qty, rate=10)
|
||||
|
||||
rows = [row for row in get_bom_data({"bom": bom.name}) if row.item_code == rm.name]
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
lines = [line for line in bom.items if line.item_code == rm.name]
|
||||
self.assertEqual(len(lines), 2)
|
||||
|
||||
self.assertAlmostEqual(
|
||||
flt(rows[0].qty_per_unit),
|
||||
sum(flt(line.qty_consumed_per_unit) for line in lines),
|
||||
places=6,
|
||||
)
|
||||
self.assertAlmostEqual(flt(rows[0].actual_qty), 10.0, places=6)
|
||||
|
||||
|
||||
def split_data_and_footer(raw_data):
|
||||
"""Separate component rows from the footer row. Skips blank spacer rows."""
|
||||
|
||||
@@ -49,6 +49,29 @@ def get_columns():
|
||||
return columns
|
||||
|
||||
|
||||
def apply_representative_lines(rows, sales_orders):
|
||||
"""Fill item_name/description from one real Sales Order Item line per group.
|
||||
|
||||
Both are editable per line, so an order 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.
|
||||
"""
|
||||
representative = {}
|
||||
if sales_orders:
|
||||
for line in frappe.get_all(
|
||||
"Sales Order Item",
|
||||
filters={"parent": ("in", sales_orders), "docstatus": 1},
|
||||
fields=["parent", "item_code", "item_name", "description"],
|
||||
order_by="idx",
|
||||
):
|
||||
representative.setdefault((line.parent, line.item_code), line)
|
||||
|
||||
for row in rows:
|
||||
line = representative.get((row.name, row.item_code))
|
||||
row.item_name = line.item_name if line else None
|
||||
row.description = line.description if line else None
|
||||
|
||||
|
||||
def get_data():
|
||||
so = frappe.qb.DocType("Sales Order")
|
||||
so_item = frappe.qb.DocType("Sales Order Item")
|
||||
@@ -58,10 +81,9 @@ def get_data():
|
||||
.on(so.name == so_item.parent)
|
||||
.select(
|
||||
so_item.item_code,
|
||||
# non-grouped columns are constant per grouped so.name / item_code -> Max() keeps the
|
||||
# GROUP BY valid on postgres while returning the same value MySQL picked.
|
||||
Max(so_item.item_name).as_("item_name"),
|
||||
Max(so_item.description).as_("description"),
|
||||
# the Sales Order columns are functionally dependent on the grouped so.name, so Max()
|
||||
# returns their single value. item_name/description belong to the line and are editable
|
||||
# per line, so they come from a representative line below.
|
||||
so.name,
|
||||
Max(so.transaction_date).as_("transaction_date"),
|
||||
Max(so.customer).as_("customer"),
|
||||
@@ -75,6 +97,7 @@ def get_data():
|
||||
)
|
||||
|
||||
sales_orders = [row.name for row in sales_order_entry]
|
||||
apply_representative_lines(sales_order_entry, sales_orders)
|
||||
mr_records = frappe.get_all(
|
||||
"Material Request Item",
|
||||
{"sales_order": ("in", sales_orders), "docstatus": 1},
|
||||
|
||||
@@ -88,6 +88,37 @@ class TestQuotationTrends(ERPNextTestSuite):
|
||||
labels, after = self.run_report(based_on="Customer")
|
||||
self.assertEqual(self._cell(after, "Party", "_Test Customer", amt_col, labels) - before_amt, 300)
|
||||
|
||||
def test_lead_quotation_label_resolves_through_quotation_to(self):
|
||||
"""party_name is a dynamic link, so the label must be resolved via quotation_to.
|
||||
|
||||
Looking the party up in Customer alone leaves a Lead's row blank, and looking in Customer
|
||||
first returns the wrong record when a Lead and a Customer share a name.
|
||||
"""
|
||||
lead_name = "_Test Trends Lead Party"
|
||||
if not frappe.db.exists("Lead", {"lead_name": lead_name}):
|
||||
frappe.get_doc({"doctype": "Lead", "lead_name": lead_name}).insert()
|
||||
lead = frappe.db.get_value("Lead", {"lead_name": lead_name}, ["name", "company_name"], as_dict=True)
|
||||
|
||||
quotation = frappe.new_doc("Quotation")
|
||||
quotation.company = "_Test Company"
|
||||
quotation.transaction_date = TXN_DATE
|
||||
quotation.currency = "INR"
|
||||
quotation.quotation_to = "Lead"
|
||||
quotation.party_name = lead.name
|
||||
quotation.append(
|
||||
"items",
|
||||
{"item_code": "_Test Item", "qty": 1, "rate": 100, "warehouse": "_Test Warehouse - _TC"},
|
||||
)
|
||||
quotation.insert()
|
||||
quotation.submit()
|
||||
|
||||
labels, rows = self.run_report(based_on="Customer")
|
||||
party_idx, name_idx = labels.index("Party"), labels.index("Party Name")
|
||||
lead_rows = [row for row in rows if row[party_idx] == lead.name]
|
||||
|
||||
self.assertEqual(len(lead_rows), 1)
|
||||
self.assertEqual(lead_rows[0][name_idx], lead.company_name or lead_name)
|
||||
|
||||
def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self):
|
||||
# _Test Item is quoted to two customers -> two detail rows under one header row.
|
||||
# _Test Item 2 is quoted to only one customer -> exactly one detail row under its
|
||||
|
||||
@@ -31,11 +31,41 @@ class TestSalesOrderTrends(ERPNextTestSuite):
|
||||
self.assertTrue(columns)
|
||||
self.assertTrue(any("_Test Item" in [str(cell) for cell in row] for row in data))
|
||||
|
||||
def test_customer_labels_come_from_the_master_not_a_stored_snapshot(self):
|
||||
"""territory and customer_name must be the Customer master's, not one order's snapshot.
|
||||
|
||||
Both are stored per transaction and editable, so historical orders can hold different values
|
||||
for one customer. Aggregating them with Max() is a text sort, and MariaDB (case-folding) and
|
||||
PostgreSQL (byte order) resolve it differently, so the two engines could label the same row
|
||||
differently. The master's values are functionally dependent on the grouped customer, so they
|
||||
are the same on both engines by construction.
|
||||
"""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_order_trends.sales_order_trends import execute
|
||||
|
||||
make_sales_order(customer="_Test Customer", item_code="_Test Item", qty=3, rate=100)
|
||||
so2 = make_sales_order(customer="_Test Customer", item_code="_Test Item", qty=2, rate=100)
|
||||
frappe.db.set_value("Sales Order", so2.name, "territory", "_Test Territory Rest Of The World")
|
||||
|
||||
master_territory, master_name = frappe.db.get_value(
|
||||
"Customer", "_Test Customer", ["territory", "customer_name"]
|
||||
)
|
||||
|
||||
columns, data, _chart_none, _chart = execute(
|
||||
{"company": "_Test Company", "period": "Monthly", "based_on": "Customer"}
|
||||
)
|
||||
|
||||
self.assertTrue(columns)
|
||||
customer_rows = [row for row in data if row[0] == "_Test Customer"]
|
||||
self.assertEqual(len(customer_rows), 1)
|
||||
self.assertEqual(customer_rows[0][1], master_name)
|
||||
self.assertEqual(customer_rows[0][2], master_territory)
|
||||
|
||||
def test_customer_with_divergent_stored_territory_stays_one_row(self):
|
||||
# territory (and customer_name) are stored per-transaction fields; historical sales docs can hold a
|
||||
# different value for the same customer. trends groups by t1.customer only and aggregates these with
|
||||
# Max(), so the report stays one row per customer on both MariaDB and Postgres. Grouping by territory
|
||||
# (the pre-fix behaviour) would split the customer into two rows.
|
||||
# different value for the same customer. The report reads both from the Customer master, so it stays
|
||||
# one row per customer on both MariaDB and Postgres. Grouping by the stored territory would split
|
||||
# the customer into two rows.
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_order_trends.sales_order_trends import execute
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import cint, flt, get_link_to_form, get_number_format_info
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
from frappe.utils.number_format import NUMBER_FORMAT_MAP, NumberFormat
|
||||
|
||||
from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import (
|
||||
get_template_details,
|
||||
@@ -84,6 +86,7 @@ class QualityInspection(Document):
|
||||
reading.status = "Accepted"
|
||||
|
||||
if self.readings:
|
||||
self.validate_reading_number_format()
|
||||
self.inspect_and_set_status()
|
||||
|
||||
self.validate_inspection_required()
|
||||
@@ -281,6 +284,47 @@ class QualityInspection(Document):
|
||||
)
|
||||
break
|
||||
|
||||
def validate_reading_number_format(self):
|
||||
"""Reject newly entered readings that are not numbers in the user's format.
|
||||
|
||||
They would otherwise be misread rather than refused, silently rejecting an
|
||||
inspection whose readings are in fact within the acceptance range. Readings
|
||||
already stored are left alone, so a document entered by a user in one locale
|
||||
stays saveable and submittable by a user in another."""
|
||||
number_format = get_reading_number_format()
|
||||
decimal_str, comma_str = get_reading_separators(number_format)
|
||||
before_save = self.get_doc_before_save()
|
||||
|
||||
for reading in self.readings:
|
||||
if not cint(reading.numeric) or cint(reading.manual_inspection):
|
||||
continue
|
||||
|
||||
stored = before_save and before_save.get("readings", {"name": reading.name})
|
||||
stored = stored[0] if stored else None
|
||||
|
||||
for i in range(1, 11):
|
||||
field = "reading_" + str(i)
|
||||
value = reading.get(field)
|
||||
if value is None or not value.strip():
|
||||
continue
|
||||
|
||||
if stored and stored.get(field) == value:
|
||||
continue
|
||||
|
||||
if parse_reading(value, decimal_str, comma_str) is None:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Reading {1} {2} is not a valid number in the {3} number format. Use {4} as the decimal separator."
|
||||
).format(
|
||||
reading.idx,
|
||||
i,
|
||||
frappe.bold(value),
|
||||
frappe.bold(number_format.string),
|
||||
frappe.bold(decimal_str),
|
||||
),
|
||||
title=_("Invalid Reading"),
|
||||
)
|
||||
|
||||
def set_status_based_on_acceptance_values(self, reading):
|
||||
if not cint(reading.numeric):
|
||||
reading_value = reading.get("reading_value") or ""
|
||||
@@ -511,17 +555,61 @@ def make_quality_inspection(source_name: str, target_doc: str | dict | Document
|
||||
return doc
|
||||
|
||||
|
||||
def get_reading_number_format() -> NumberFormat:
|
||||
"""Number format the user enters readings in.
|
||||
|
||||
User defaults fall back to the global default, so this is the same format the
|
||||
user's desk formats numbers with."""
|
||||
number_format = frappe.defaults.get_user_default("number_format")
|
||||
if number_format not in NUMBER_FORMAT_MAP:
|
||||
number_format = "#,###.##"
|
||||
|
||||
return NumberFormat.from_string(number_format)
|
||||
|
||||
|
||||
def get_reading_separators(number_format: NumberFormat) -> tuple[str, str]:
|
||||
"""Decimal and thousands separator a reading may be written with.
|
||||
|
||||
A format with no decimal separator still has to accept decimal readings, so it
|
||||
falls back to a dot and gives up any grouping that would collide with it."""
|
||||
decimal_str = number_format.decimal_separator or "."
|
||||
comma_str = number_format.thousands_separator
|
||||
|
||||
return decimal_str, "" if comma_str == decimal_str else comma_str
|
||||
|
||||
|
||||
def parse_reading(value: str, decimal_str: str, comma_str: str) -> float | None:
|
||||
"""Reading as a float, or None when it is not a number in that format."""
|
||||
value = value.strip()
|
||||
integer_part = value.partition(decimal_str)[0]
|
||||
|
||||
if comma_str and comma_str in integer_part:
|
||||
groups = integer_part.split(comma_str)
|
||||
lead = groups[0][1:] if groups[0][:1] in ("+", "-") else groups[0]
|
||||
if not 1 <= len(lead) <= 3 or len(groups[-1]) != 3:
|
||||
return None
|
||||
|
||||
if any(len(group) not in (2, 3) for group in groups[1:-1]):
|
||||
return None
|
||||
|
||||
value = value.replace(comma_str, "")
|
||||
|
||||
if decimal_str != ".":
|
||||
value = value.replace(decimal_str, ".")
|
||||
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return number if isfinite(number) else None
|
||||
|
||||
|
||||
def parse_float(num: str) -> float:
|
||||
"""Since reading_# fields are `Data` field they might contain number which
|
||||
is representation in user's prefered number format instead of machine
|
||||
readable format. This function converts them to machine readable format."""
|
||||
|
||||
number_format = frappe.db.get_default("number_format") or "#,###.##"
|
||||
decimal_str, comma_str, _number_format_precision = get_number_format_info(number_format)
|
||||
decimal_str, comma_str = get_reading_separators(get_reading_number_format())
|
||||
|
||||
if decimal_str == "," and comma_str == ".":
|
||||
num = num.replace(",", "#$")
|
||||
num = num.replace(".", ",")
|
||||
num = num.replace("#$", ".")
|
||||
|
||||
return flt(num)
|
||||
return flt(parse_reading(num, decimal_str, comma_str))
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
|
||||
# See license.txt
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowdate
|
||||
from frappe.utils.number_format import NumberFormat
|
||||
|
||||
from erpnext.controllers.stock_controller import (
|
||||
QualityInspectionNotSubmittedError,
|
||||
@@ -12,10 +15,29 @@ from erpnext.controllers.stock_controller import (
|
||||
)
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.quality_inspection.quality_inspection import (
|
||||
get_reading_separators,
|
||||
parse_reading,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@contextmanager
|
||||
def user_number_format(number_format):
|
||||
"""Temporarily set the session user's own number format."""
|
||||
user = frappe.session.user
|
||||
previous = frappe.db.get_value("DefaultValue", {"parent": user, "defkey": "number_format"}, "defvalue")
|
||||
frappe.defaults.set_user_default("number_format", number_format)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if previous:
|
||||
frappe.defaults.set_user_default("number_format", previous)
|
||||
else:
|
||||
frappe.defaults.clear_user_default("number_format")
|
||||
|
||||
|
||||
class TestQualityInspection(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -108,7 +130,6 @@ class TestQualityInspection(ERPNextTestSuite):
|
||||
"acceptance_formula": "mean < 0.9",
|
||||
"reading_1": "0.5",
|
||||
"reading_2": "0.7",
|
||||
"reading_3": "random text", # check if random string input causes issues
|
||||
},
|
||||
{
|
||||
"specification": "Calcium Content", # non-numeric reading
|
||||
@@ -252,6 +273,208 @@ class TestQualityInspection(ERPNextTestSuite):
|
||||
qa.delete()
|
||||
dn.delete()
|
||||
|
||||
def test_non_numeric_reading(self):
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
create_quality_inspection_parameter("Density")
|
||||
|
||||
readings = [
|
||||
{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "random text"}
|
||||
]
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, qa.save)
|
||||
|
||||
dn.delete()
|
||||
|
||||
def test_non_numeric_reading_in_formula_based_criteria(self):
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
create_quality_inspection_parameter("Density")
|
||||
|
||||
readings = [
|
||||
{
|
||||
"specification": "Density",
|
||||
"formula_based_criteria": 1,
|
||||
"acceptance_formula": "mean < 0.9",
|
||||
"reading_1": "0.5",
|
||||
"reading_2": "0.7",
|
||||
"reading_3": "random text",
|
||||
}
|
||||
]
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, qa.save)
|
||||
|
||||
dn.delete()
|
||||
|
||||
def test_manual_inspection_reading_is_not_number_checked(self):
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
create_quality_inspection_parameter("Density")
|
||||
|
||||
readings = [
|
||||
{
|
||||
"specification": "Density",
|
||||
"manual_inspection": 1,
|
||||
"status": "Accepted",
|
||||
"min_value": 1.15,
|
||||
"max_value": 1.20,
|
||||
"reading_1": "1.15 g/cm3",
|
||||
}
|
||||
]
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True
|
||||
)
|
||||
qa.save()
|
||||
|
||||
self.assertEqual(qa.readings[0].status, "Accepted")
|
||||
|
||||
qa.delete()
|
||||
dn.delete()
|
||||
|
||||
def test_reading_in_comma_decimal_number_format(self):
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
create_quality_inspection_parameter("Density")
|
||||
|
||||
readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}]
|
||||
with user_number_format("#.###,##"):
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True
|
||||
)
|
||||
qa.save()
|
||||
|
||||
self.assertEqual(qa.readings[0].status, "Accepted")
|
||||
self.assertEqual(qa.status, "Accepted")
|
||||
|
||||
qa.delete()
|
||||
dn.delete()
|
||||
|
||||
def test_reading_in_space_grouped_number_format(self):
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
create_quality_inspection_parameter("Density")
|
||||
|
||||
readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}]
|
||||
with user_number_format("# ###,##"):
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True
|
||||
)
|
||||
qa.save()
|
||||
|
||||
self.assertEqual(qa.readings[0].status, "Accepted")
|
||||
self.assertEqual(qa.status, "Accepted")
|
||||
|
||||
qa.delete()
|
||||
dn.delete()
|
||||
|
||||
def test_reading_in_wrong_decimal_number_format(self):
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
create_quality_inspection_parameter("Density")
|
||||
|
||||
readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1.15"}]
|
||||
with user_number_format("#.###,##"):
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, qa.save)
|
||||
|
||||
dn.delete()
|
||||
|
||||
def test_reading_with_comma_in_dot_decimal_number_format(self):
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
create_quality_inspection_parameter("Density")
|
||||
|
||||
readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}]
|
||||
with user_number_format("#,###.##"):
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, qa.save)
|
||||
|
||||
dn.delete()
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###.##"})
|
||||
def test_reading_number_format_prefers_the_user_over_the_system(self):
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
create_quality_inspection_parameter("Density")
|
||||
|
||||
readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}]
|
||||
with user_number_format("#.###,##"):
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True
|
||||
)
|
||||
qa.save()
|
||||
|
||||
self.assertEqual(qa.readings[0].status, "Accepted")
|
||||
|
||||
qa.delete()
|
||||
dn.delete()
|
||||
|
||||
def test_stored_reading_stays_submittable_in_another_number_format(self):
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
create_quality_inspection_parameter("Density")
|
||||
|
||||
readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}]
|
||||
with user_number_format("#.###,##"):
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True
|
||||
)
|
||||
qa.save()
|
||||
|
||||
with user_number_format("#,###.##"):
|
||||
qa.reload()
|
||||
qa.submit()
|
||||
|
||||
self.assertEqual(qa.docstatus, 1)
|
||||
|
||||
qa.cancel()
|
||||
qa.delete()
|
||||
dn.delete()
|
||||
|
||||
def test_parse_reading_in_every_number_format(self):
|
||||
accepted = [
|
||||
("#,###.##", "1.15", 1.15),
|
||||
("#,###.##", "1,234.56", 1234.56),
|
||||
("#,##,###.##", "12,34,567.89", 1234567.89),
|
||||
("#,###.###", "1,234.567", 1234.567),
|
||||
("#.###,##", "1,15", 1.15),
|
||||
("#.###,##", "1.234,56", 1234.56),
|
||||
("# ###,##", "1,15", 1.15),
|
||||
("# ###,##", "1.15", 1.15),
|
||||
("# ###,##", "1 234,56", 1234.56),
|
||||
("# ###.##", "1 234.56", 1234.56),
|
||||
("#'###.##", "1'234.56", 1234.56),
|
||||
("#, ###.##", "1, 234.56", 1234.56),
|
||||
("#.########", "1.15", 1.15),
|
||||
("#,###", "1.5", 1.5),
|
||||
("#,###", "1,500", 1500.0),
|
||||
("#.###", "1.5", 1.5),
|
||||
("#.###", "1.500", 1.5),
|
||||
("#,###.##", "-1,234.56", -1234.56),
|
||||
]
|
||||
refused = [
|
||||
("#,###.##", "1,15"),
|
||||
("#.###,##", "1.15"),
|
||||
("#,###.##", "--1.15"),
|
||||
("#,###.##", "1²"),
|
||||
("#,###.##", "nan"),
|
||||
("#,###.##", "random text"),
|
||||
("#,###", "1,50"),
|
||||
]
|
||||
|
||||
for number_format, value, expected in accepted:
|
||||
decimal_str, comma_str = get_reading_separators(NumberFormat.from_string(number_format))
|
||||
with self.subTest(number_format=number_format, value=value):
|
||||
self.assertEqual(parse_reading(value, decimal_str, comma_str), expected)
|
||||
|
||||
for number_format, value in refused:
|
||||
decimal_str, comma_str = get_reading_separators(NumberFormat.from_string(number_format))
|
||||
with self.subTest(number_format=number_format, value=value):
|
||||
self.assertIsNone(parse_reading(value, decimal_str, comma_str))
|
||||
|
||||
def test_delete_quality_inspection_linked_with_stock_entry(self):
|
||||
item_code = create_item("_Test Cicuular Dependecy Item with QA").name
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Max, Min, NullIf, Sum
|
||||
from frappe.query_builder.functions import Min, NullIf, Sum
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
@@ -348,35 +348,16 @@ class DisassembleStockEntry(BaseStockEntry):
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
# Aggregating across all Manufacture entries of the work order, one row per item_code.
|
||||
# The non-grouped columns are constant per item_code in practice (an item plays one role with
|
||||
# one uom/warehouse across the WO's manufacture entries); Max() keeps the GROUP BY valid on
|
||||
# postgres while returning the value MySQL picked arbitrarily, preserving the one-row-per-item
|
||||
# shape the disassembly expects.
|
||||
return (
|
||||
# Aggregate in stock UOM: qty is expressed in each row's selected UOM and cannot be added
|
||||
# when manufacture entries use different UOMs for the same item. basic_rate is also per
|
||||
# stock UOM, so weight it by transfer_qty. Manufacture rows always carry positive stock
|
||||
# qty, so NullIf only guards a theoretical /0.
|
||||
rows = (
|
||||
query.select(
|
||||
Sum(SED.qty).as_("qty"),
|
||||
Sum(SED.transfer_qty).as_("transfer_qty"),
|
||||
SED.item_code,
|
||||
Max(SED.item_name).as_("item_name"),
|
||||
Max(SED.description).as_("description"),
|
||||
Max(SED.stock_uom).as_("stock_uom"),
|
||||
Max(SED.uom).as_("uom"),
|
||||
# qty-weighted average so consolidating an item across manufacture entries at different
|
||||
# valuation rates values the summed qty correctly (Max would bias the rate high).
|
||||
# Manufacture rows always carry positive qty, so NullIf only guards a theoretical /0.
|
||||
(Sum(SED.basic_rate * SED.qty) / NullIf(Sum(SED.qty), 0)).as_("basic_rate"),
|
||||
Max(SED.conversion_factor).as_("conversion_factor"),
|
||||
Max(SED.is_finished_item).as_("is_finished_item"),
|
||||
Max(SED.secondary_item_type).as_("secondary_item_type"),
|
||||
Max(SED.is_legacy_scrap_item).as_("is_legacy_scrap_item"),
|
||||
Max(SED.bom_secondary_item).as_("bom_secondary_item"),
|
||||
Max(SED.batch_no).as_("batch_no"),
|
||||
Max(SED.serial_no).as_("serial_no"),
|
||||
Max(SED.use_serial_batch_fields).as_("use_serial_batch_fields"),
|
||||
Max(SED.s_warehouse).as_("s_warehouse"),
|
||||
Max(SED.t_warehouse).as_("t_warehouse"),
|
||||
Max(SED.bom_no).as_("bom_no"),
|
||||
Sum(SED.transfer_qty).as_("qty"),
|
||||
Sum(SED.transfer_qty).as_("transfer_qty"),
|
||||
(Sum(SED.basic_rate * SED.transfer_qty) / NullIf(Sum(SED.transfer_qty), 0)).as_("basic_rate"),
|
||||
)
|
||||
.where(SE.purpose == "Manufacture")
|
||||
.where(SE.work_order == self.doc.work_order)
|
||||
@@ -385,6 +366,61 @@ class DisassembleStockEntry(BaseStockEntry):
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
representative = self.get_representative_manufacture_rows()
|
||||
for row in rows:
|
||||
row.update(representative.get(row.item_code) or {})
|
||||
row.uom = row.stock_uom
|
||||
row.conversion_factor = 1
|
||||
|
||||
return rows
|
||||
|
||||
def get_representative_manufacture_rows(self):
|
||||
"""Earliest posted line per item across the work order's Manufacture entries.
|
||||
|
||||
The disassembly wants one row per item, but some descriptive columns describe a line, not
|
||||
an item: batch_no and serial_no only mean something beside their warehouse, and
|
||||
is_finished_item decides whether the row is the output or an input. Aggregating each column
|
||||
on its own can pair values from different lines into a row that was never posted, so take
|
||||
the columns from a single real line instead. UOM is normalized separately to stock UOM.
|
||||
"""
|
||||
SE = frappe.qb.DocType("Stock Entry")
|
||||
SED = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
lines = (
|
||||
frappe.qb.from_(SED)
|
||||
.join(SE)
|
||||
.on(SED.parent == SE.name)
|
||||
.select(
|
||||
SED.item_code,
|
||||
SED.item_name,
|
||||
SED.description,
|
||||
SED.stock_uom,
|
||||
SED.is_finished_item,
|
||||
SED.secondary_item_type,
|
||||
SED.is_legacy_scrap_item,
|
||||
SED.bom_secondary_item,
|
||||
SED.batch_no,
|
||||
SED.serial_no,
|
||||
SED.use_serial_batch_fields,
|
||||
SED.s_warehouse,
|
||||
SED.t_warehouse,
|
||||
SED.bom_no,
|
||||
)
|
||||
.where(
|
||||
(SE.docstatus == 1) & (SE.purpose == "Manufacture") & (SE.work_order == self.doc.work_order)
|
||||
)
|
||||
.orderby(SE.creation)
|
||||
.orderby(SE.name)
|
||||
.orderby(SED.idx)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
representative = {}
|
||||
for line in lines:
|
||||
representative.setdefault(line.item_code, line)
|
||||
|
||||
return representative
|
||||
|
||||
def on_submit(self):
|
||||
self.set_serial_batch_for_disassembly()
|
||||
self.update_disassembled_order()
|
||||
|
||||
@@ -1007,11 +1007,9 @@ def get_secondary_items_from_job_card(work_order, jc_name=None):
|
||||
.select(
|
||||
Sum(job_card_secondary_item.stock_qty).as_("stock_qty"),
|
||||
job_card_secondary_item.item_code,
|
||||
# non-grouped columns are item attributes / the secondary-item BOM link, constant per
|
||||
# grouped (item_code, secondary_item_type) -> Max() keeps the GROUP BY valid on postgres
|
||||
# while returning the value MySQL picked arbitrarily.
|
||||
Max(job_card_secondary_item.item_name).as_("item_name"),
|
||||
Max(job_card_secondary_item.description).as_("description"),
|
||||
# stock_uom and the secondary-item BOM link are constant per grouped
|
||||
# (item_code, secondary_item_type) -> Max() returns their single value. item_name and
|
||||
# description are editable per line, so they come from a representative line below.
|
||||
Max(job_card_secondary_item.stock_uom).as_("stock_uom"),
|
||||
job_card_secondary_item.secondary_item_type,
|
||||
Max(job_card_secondary_item.bom_secondary_item).as_("bom_secondary_item"),
|
||||
@@ -1030,7 +1028,41 @@ def get_secondary_items_from_job_card(work_order, jc_name=None):
|
||||
if jc_name:
|
||||
secondary_items = secondary_items.where(job_card.name == jc_name)
|
||||
|
||||
return secondary_items.run(as_dict=1)
|
||||
rows = secondary_items.run(as_dict=1)
|
||||
apply_representative_secondary_lines(rows, work_order, jc_name)
|
||||
return rows
|
||||
|
||||
|
||||
def apply_representative_secondary_lines(rows, work_order, jc_name=None):
|
||||
"""Fill item_name/description from one real Job Card Secondary Item line per group.
|
||||
|
||||
Both are editable per line, so the same secondary item across a work order's job cards can
|
||||
carry several values per group. Aggregating them sorts text, and MariaDB folds case while
|
||||
PostgreSQL orders by byte value, so the engines pick differently.
|
||||
"""
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": work_order, "docstatus": 1, **({"name": jc_name} if jc_name else {})},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
representative = {}
|
||||
if job_cards:
|
||||
for line in frappe.get_all(
|
||||
"Job Card Secondary Item",
|
||||
filters={"parent": ("in", job_cards)},
|
||||
# idx first, so the rule really is "first by idx"; creation breaks ties across job cards.
|
||||
# Never order by parent -- the Job Card name is text, and sorting text is the divergence
|
||||
# this is here to avoid.
|
||||
fields=["item_code", "secondary_item_type", "item_name", "description"],
|
||||
order_by="idx, creation",
|
||||
):
|
||||
representative.setdefault((line.item_code, line.secondary_item_type), line)
|
||||
|
||||
for row in rows:
|
||||
line = representative.get((row.item_code, row.secondary_item_type))
|
||||
row.item_name = line.item_name if line else None
|
||||
row.description = line.description if line else None
|
||||
|
||||
|
||||
def get_previous_operation_output_sn_batch(work_order, item_code, warehouse):
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
"description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.",
|
||||
"fieldname": "over_delivery_receipt_allowance",
|
||||
"fieldtype": "Float",
|
||||
"label": "Over Delivery/Receipt Allowance (%)"
|
||||
"label": "Over Delivery/Receipt Allowance (%)",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "Stop",
|
||||
@@ -276,7 +277,8 @@
|
||||
"description": "The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units.",
|
||||
"fieldname": "mr_qty_allowance",
|
||||
"fieldtype": "Float",
|
||||
"label": "Over Transfer Allowance (%)"
|
||||
"label": "Over Transfer Allowance (%)",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -437,7 +439,8 @@
|
||||
"description": "The percentage you are allowed to pick more items in the pick list than the ordered quantity.",
|
||||
"fieldname": "over_picking_allowance",
|
||||
"fieldtype": "Percent",
|
||||
"label": "Over Picking Allowance (%)"
|
||||
"label": "Over Picking Allowance (%)",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
@@ -590,7 +593,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-16 17:00:00.000000",
|
||||
"modified": "2026-08-01 23:35:02.896836",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Settings",
|
||||
|
||||
@@ -68,7 +68,7 @@ class StockSettings(Document):
|
||||
use_naming_series: DF.Check
|
||||
use_serial_batch_fields: DF.Check
|
||||
validate_material_transfer_warehouses: DF.Check
|
||||
valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO"]
|
||||
valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO", "Standard Cost"]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
@@ -101,6 +101,7 @@ class StockSettings(Document):
|
||||
validate_fields_for_doctype=False,
|
||||
)
|
||||
|
||||
self.validate_over_delivery_receipt_allowance()
|
||||
self.validate_serial_and_batch_no_settings()
|
||||
self.cant_change_valuation_method()
|
||||
self.validate_clean_description_html()
|
||||
@@ -112,6 +113,10 @@ class StockSettings(Document):
|
||||
self.change_precision_for_stock_entry()
|
||||
self.validate_do_not_use_batchwise_valuation()
|
||||
|
||||
def validate_over_delivery_receipt_allowance(self):
|
||||
if not self.over_delivery_receipt_allowance:
|
||||
self.role_allowed_to_over_deliver_receive = None
|
||||
|
||||
def validate_do_not_use_batchwise_valuation(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if not doc_before_save:
|
||||
|
||||
Reference in New Issue
Block a user