diff --git a/.github/POSTGRES_COMPATIBILITY.md b/.github/POSTGRES_COMPATIBILITY.md index 9dee4cb0f0d..e72529ce025 100644 --- a/.github/POSTGRES_COMPATIBILITY.md +++ b/.github/POSTGRES_COMPATIBILITY.md @@ -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. diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 27928ee8bbc..1abcd7683e7 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -6,7 +6,27 @@ cd ~ || exit githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}} frappeuser=${FRAPPE_USER:-"frappe"} -frappecommitish=${FRAPPE_BRANCH:-$githubbranch} +frappecommitish=${FRAPPE_BRANCH:-} + +# A stacked pull request targets another erpnext branch, which has no counterpart in frappe. +# Fall back to develop so the bench is still installed. An explicit FRAPPE_BRANCH is trusted as +# given, since it can be a commit sha rather than a branch. +if [ -z "$frappecommitish" ]; then + frappecommitish=$githubbranch + + # git ls-remote --exit-code reports 2 for a branch that is not there and 128 for a remote it + # could not reach. Only the first one is proof of absence; keep the branch on anything else so + # a flaky probe cannot install an unrelated frappe. + probe=0 + git ls-remote --exit-code --heads "https://github.com/${frappeuser}/frappe" "$frappecommitish" >/dev/null 2>&1 || probe=$? + + if [ "$probe" -eq 2 ]; then + echo "frappe has no branch ${frappecommitish}, falling back to develop" + frappecommitish=develop + elif [ "$probe" -ne 0 ]; then + echo "could not reach frappe to check for branch ${frappecommitish} (git ls-remote exited ${probe}), keeping it" + fi +fi db_host=${DB_HOST:-"127.0.0.1"} db_user_host=${DB_USER_HOST:-"localhost"} wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"} diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 83c2d7ff925..e8eaa4f8ae4 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -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" diff --git a/.github/workflows/review-translation-changes.yaml b/.github/workflows/review-translation-changes.yaml index fafc3245106..4405b412c23 100644 --- a/.github/workflows/review-translation-changes.yaml +++ b/.github/workflows/review-translation-changes.yaml @@ -23,3 +23,5 @@ jobs: steps: - uses: alyf-de/po-review-action@v1.1.0 + with: + hidden-po-files: eo.po diff --git a/.greptile/config.json b/.greptile/config.json index e3492ac7c0d..1348468c611 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -7,7 +7,7 @@ "frappe/frappe" ] }, - "instructions": "ERPNext runs on both MariaDB and PostgreSQL from one codebase, but the PostgreSQL test job is label-gated and may not run on this PR, so review every new or changed database query (raw frappe.db.sql, frappe.qb, frappe.get_all/get_list/get_value, and report SQL) for cross-engine compatibility. PRIME RULE: MariaDB output must never change; PostgreSQL is bent to match MariaDB, never the reverse, so a change to the value, row count, or ordering MariaDB produced is a regression even if it looks more correct (the only accepted change is replacing an arbitrary/undefined result with a deterministic one, row count preserved, and it should be called out). Flag a changed query that (1) would ERROR on PostgreSQL: loose GROUP BY (selecting/ordering a column neither grouped nor aggregated -- including an aggregate like Sum()/Count() selected next to bare columns with NO .groupby() at all), MySQL-only functions (TIMESTAMP(date,time), TIMEDIFF, STR_TO_DATE, DATE_FORMAT, DATE_ADD/DATE_SUB, GROUP_CONCAT, PERIOD_DIFF, SQL IF()), .rlike()/RLIKE (frappe rewrites REGEXP->~* on PostgreSQL but does NOT translate RLIKE; use .regexp()), .like()/LIKE on a NON-text column such as idx/docstatus (bigint ILIKE has no operator; Cast_(col,'varchar') first), CAST AS CHAR / Cast(x,'char') (bare CHAR is character(1) on PostgreSQL and truncates multi-digit values; use 'varchar'), UPDATE..JOIN, HAVING on a SELECT alias, SELECT DISTINCT with an ORDER BY expr not in the select list, single-quoted column aliases, varchar bitwise OR, capital-cased identifiers used as fieldnames in get_value(dt,dn,'Status') or get_all(dt,fields=['Account']) (PostgreSQL matches the quoted identifier case-sensitively; use the stored lower-case name), a Python bool written to a Check/int column via set_value/db_set/qb.update().set() instead of 1/0, or IfNull/Coalesce of a typed column with a different-typed literal such as IfNull(date_col, 0) -> COALESCE(date, integer) (PostgreSQL: 'COALESCE types date and integer cannot be matched'; the common IfNull(date,0) != 0 / == 0 presence test should be date_col.isnotnull() / .isnull(), else coalesce to a same-type default), or division by a possibly-zero divisor (Sum(a)/Sum(b) or x/col where the data can drive the divisor to 0 -- MariaDB returns NULL for division by zero but PostgreSQL raises 'division by zero' and aborts the query, so wrap the divisor in NullIf(divisor, 0)); or (2) would SILENTLY DIVERGE across engines: case-sensitive ==/.isin()/Strpos on USER-ENTERED free-text columns such as Data/Small Text/Long Text but NOT on Link/Select/name columns where exact-case matching is intended (PostgreSQL is case-sensitive, use Lower() both sides), lowercasing a value used as a document-name lookup, empty-string vs NULL in Concat/Concat_ws (MariaDB CONCAT(x,NULL) is NULL but PostgreSQL CONCAT drops the NULL, so a label like Concat('MFG-', nullable_date) leaks a bare 'MFG-' on PostgreSQL -- guard with Case/Coalesce/NullIf), NULL ordering (PostgreSQL sorts NULLs last) in ORDER BY..LIMIT 1, integer division (int/int truncates on PostgreSQL; multiply by 100.0 or make a literal a float, e.g. col/1440 -> col/1440.0), get_all(distinct=True, order_by=...) (frappe DROPS the ORDER BY for distinct queries on PostgreSQL, so sort in Python with key=str.casefold), an engine-specific function rewrite that does not match MariaDB on edge cases, or UnixTimestamp(date)/date-to-epoch math that is timezone-dependent (a strict epoch <= now bound is flaky on PostgreSQL). Also flag CATCH-AND-CONTINUE inserts: on PostgreSQL a failed insert aborts the WHOLE transaction (InFailedSqlTransaction), so code that swallows a duplicate/unique error and keeps going in the same transaction must wrap the fallible insert in frappe.db.savepoint(name) + rollback(save_point=name), unless it re-throws with no DB call before the throw or the insert uses ignore_if_duplicate=True or autoname='hash'. When RECOVERING the poisoned txn, prefer a SCOPED savepoint over a full frappe.db.rollback(): a full rollback discards rows the handler already created before the failure -- which MariaDB keeps -- so it is a silent MariaDB regression. 'The bg job / whitelist entrypoint owns the txn' does NOT make a full rollback safe if it did multiple inserts in a loop first; a full rollback is safe only when it immediately re-throws/raises, has nothing successful before it (single op), or the batch is meant to be atomic (a partial result is invalid -> rollback + mark Failed is correct). Otherwise use a per-iteration/per-record savepoint, and keep the function's success/None return contract (don't return a value for a doc that was just rolled back). GROUP BY ROW-COUNT TRAP (most important): to make a loose GROUP BY PostgreSQL-valid, do NOT add a non-functionally-dependent column (the classic traps are the child/row primary key or an editable per-row field) to GROUP BY because that splits one row into N and changes the MariaDB row count; Max()/Min()-wrap it instead (row count preserved, value arbitrary to deterministic). Judge functional dependence by the SOURCE TABLE: a column from a master joined on the group key is FD and safe in GROUP BY, but a descriptive field on the transaction table (e.g. t1.supplier_name, t1.territory) is NOT FD and must be wrapped. The SAME row-count trap applies to SELECT DISTINCT: to satisfy PostgreSQL's ORDER-BY-expr-must-appear-in-the-select rule, do NOT blindly add the ordered column to the select -- if it is not single-valued per existing distinct row the DISTINCT key grows and MariaDB returns MORE rows (a regression); add it only if functionally dependent on the existing select columns, otherwise drop the SQL ORDER BY and sort in Python (key=str.casefold). Do NOT suggest changing a Max()-wrapped column to Sum() to make a number more correct, that changes MariaDB's value. SECOND-ORDER GROUP BY TRAPS (the Max()/Min() wrap itself can be the bug -- a wrap is only a no-op when the column is provably single-valued per group): (a) INCOHERENT PAIR: two semantically-coupled columns (a flag + a link like is_phantom_item + bom_no, a discriminator + its value) aggregated with INDEPENDENT Max()/Min() can pair values from DIFFERENT rows into a chimera row that never existed (MariaDB's loose pick was at least row-coherent) -- when a consumer uses the two values together (recursion into the link gated by the flag, dict keys, link+flag display) require grouping by the pair or a single representative-row subquery (Min(child.name) + join back). (b) NULL-SKIPPING: Max/Min ignore NULLs, so Max() over a mostly-NULL discriminator deterministically prefers the non-NULL value where MariaDB could return NULL -- flag when 'no value' is a meaningful state (fallback gates like 'if x:', dict keys, status decisions). (c) FABRICATED ARITHMETIC: Sum(x) * Max(y) -- or Python arithmetic combining a Sum'd and a Max'd column from the same grouped query -- where y can vary within the group invents a value no row ever had and Max biases it upward; require per-row Sum(x*y) when it feeds validation, budgets, valuation, or GL/stock values. (d) WRONG BOUND: when the aggregated value has a semantic, the bound must be chosen deliberately (Min(schedule_date) for a 'required by' date, Min(idx) for first-line ordering, a qty-weighted average for a rate); a blind Max can understate urgency or overstate a figure. Heuristic: if switching Max<->Min would change the answer, the column is NOT functionally dependent and wrapping either is the wrong fix -- group by it, restructure, or pick a bound for a stated reason with a test. REFACTOR / CONVERSION FAITHFULNESS: a commit labeled a 'refactor' or a raw-frappe.db.sql->frappe.qb/ORM conversion is meant to preserve behaviour but easily does not, and the change slips past the static checker and a one-engine green run -- diff the WHERE/predicate, the JOIN/ON conditions and the resulting ROW SET, not just the SELECT shape. A conversion that silently widens or narrows the filter (e.g. a 'posting_datetime > X' bound gaining an OR (posting_datetime == X AND creation > args.creation) branch under a sql->qb refactor) changes the rows touched on BOTH engines and is a regression hiding under a refactor label; call it out and require a test even if it is a deliberate bug-fix. DO NOT FLAG these false positives: .like()/['like'] on a TEXT column (already ILIKE on PostgreSQL -- but DO flag it on a non-text/integer column, see above), raw ifnull/backticks/LOCATE/REGEXP/.regexp() inside frappe.db.sql (auto-translated by the framework -- but RLIKE/.rlike() is NOT translated, see above), or an ORDER BY..LIMIT 1 tie where adding a tiebreaker would change MariaDB's current pick. Full catalog with examples and portable fixes is in .github/POSTGRES_COMPATIBILITY.md.", + "instructions": "ERPNext runs on both MariaDB and PostgreSQL from one codebase, but the PostgreSQL test job is label-gated and may not run on this PR, so review every new or changed database query (raw frappe.db.sql, frappe.qb, frappe.get_all/get_list/get_value, and report SQL) for cross-engine compatibility. PRIME RULE: MariaDB output must never change; PostgreSQL is bent to match MariaDB, never the reverse, so a change to the value, row count, or ordering MariaDB produced is a regression even if it looks more correct (the only accepted change is replacing an arbitrary/undefined result with a deterministic one, row count preserved, and it should be called out). Flag a changed query that (1) would ERROR on PostgreSQL: loose GROUP BY (selecting/ordering a column neither grouped nor aggregated -- including an aggregate like Sum()/Count() selected next to bare columns with NO .groupby() at all), MySQL-only functions (TIMESTAMP(date,time), TIMEDIFF, STR_TO_DATE, DATE_FORMAT, DATE_ADD/DATE_SUB, GROUP_CONCAT, PERIOD_DIFF, SQL IF()), .rlike()/RLIKE (frappe rewrites REGEXP->~* on PostgreSQL but does NOT translate RLIKE; use .regexp()), .like()/LIKE on a NON-text column such as idx/docstatus (bigint ILIKE has no operator; Cast_(col,'varchar') first), CAST AS CHAR / Cast(x,'char') (bare CHAR is character(1) on PostgreSQL and truncates multi-digit values; use 'varchar'), UPDATE..JOIN, HAVING on a SELECT alias, SELECT DISTINCT with an ORDER BY expr not in the select list, single-quoted column aliases, varchar bitwise OR, capital-cased identifiers used as fieldnames in get_value(dt,dn,'Status') or get_all(dt,fields=['Account']) (PostgreSQL matches the quoted identifier case-sensitively; use the stored lower-case name), a Python bool written to a Check/int column via set_value/db_set/qb.update().set() instead of 1/0, or IfNull/Coalesce of a typed column with a different-typed literal such as IfNull(date_col, 0) -> COALESCE(date, integer) (PostgreSQL: 'COALESCE types date and integer cannot be matched'; the common IfNull(date,0) != 0 / == 0 presence test should be date_col.isnotnull() / .isnull(), else coalesce to a same-type default), or division by a possibly-zero divisor (Sum(a)/Sum(b) or x/col where the data can drive the divisor to 0 -- MariaDB returns NULL for division by zero but PostgreSQL raises 'division by zero' and aborts the query, so wrap the divisor in NullIf(divisor, 0)); or (2) would SILENTLY DIVERGE across engines: case-sensitive ==/.isin()/Strpos on USER-ENTERED free-text columns such as Data/Small Text/Long Text but NOT on Link/Select/name columns where exact-case matching is intended (PostgreSQL is case-sensitive, use Lower() both sides), lowercasing a value used as a document-name lookup, empty-string vs NULL in Concat/Concat_ws (MariaDB CONCAT(x,NULL) is NULL but PostgreSQL CONCAT drops the NULL, so a label like Concat('MFG-', nullable_date) leaks a bare 'MFG-' on PostgreSQL -- guard with Case/Coalesce/NullIf), NULL ordering (PostgreSQL sorts NULLs last) in ORDER BY..LIMIT 1, integer division (int/int truncates on PostgreSQL; multiply by 100.0 or make a literal a float, e.g. col/1440 -> col/1440.0), get_all(distinct=True, order_by=...) (frappe DROPS the ORDER BY for distinct queries on PostgreSQL, so sort in Python with key=str.casefold), an engine-specific function rewrite that does not match MariaDB on edge cases, or UnixTimestamp(date)/date-to-epoch math that is timezone-dependent (a strict epoch <= now bound is flaky on PostgreSQL). Also flag CATCH-AND-CONTINUE inserts: on PostgreSQL a failed insert aborts the WHOLE transaction (InFailedSqlTransaction), so code that swallows a duplicate/unique error and keeps going in the same transaction must wrap the fallible insert in frappe.db.savepoint(name) + rollback(save_point=name), unless it re-throws with no DB call before the throw or the insert uses ignore_if_duplicate=True or autoname='hash'. When RECOVERING the poisoned txn, prefer a SCOPED savepoint over a full frappe.db.rollback(): a full rollback discards rows the handler already created before the failure -- which MariaDB keeps -- so it is a silent MariaDB regression. 'The bg job / whitelist entrypoint owns the txn' does NOT make a full rollback safe if it did multiple inserts in a loop first; a full rollback is safe only when it immediately re-throws/raises, has nothing successful before it (single op), or the batch is meant to be atomic (a partial result is invalid -> rollback + mark Failed is correct). Otherwise use a per-iteration/per-record savepoint, and keep the function's success/None return contract (don't return a value for a doc that was just rolled back). GROUP BY ROW-COUNT TRAP (most important): to make a loose GROUP BY PostgreSQL-valid, do NOT add a non-functionally-dependent column (the classic traps are the child/row primary key or an editable per-row field) to GROUP BY because that splits one row into N and changes the MariaDB row count; Max()/Min()-wrap it instead (row count preserved, value arbitrary to deterministic). Judge functional dependence by the SOURCE TABLE: a column from a master joined on the group key is FD and safe in GROUP BY, but a descriptive field on the transaction table (e.g. t1.supplier_name, t1.territory) is NOT FD and must be wrapped. The SAME row-count trap applies to SELECT DISTINCT: to satisfy PostgreSQL's ORDER-BY-expr-must-appear-in-the-select rule, do NOT blindly add the ordered column to the select -- if it is not single-valued per existing distinct row the DISTINCT key grows and MariaDB returns MORE rows (a regression); add it only if functionally dependent on the existing select columns, otherwise drop the SQL ORDER BY and sort in Python (key=str.casefold). Do NOT suggest changing a Max()-wrapped column to Sum() to make a number more correct, that changes MariaDB's value. SECOND-ORDER GROUP BY TRAPS (the Max()/Min() wrap itself can be the bug -- a wrap is only a no-op when the column is provably single-valued per group): (a) INCOHERENT PAIR: two semantically-coupled columns (a flag + a link like is_phantom_item + bom_no, a discriminator + its value) aggregated with INDEPENDENT Max()/Min() can pair values from DIFFERENT rows into a chimera row that never existed (MariaDB's loose pick was at least row-coherent) -- when a consumer uses the two values together (recursion into the link gated by the flag, dict keys, link+flag display) require grouping by the pair or a single representative-row subquery (Min(child.name) + join back). (b) COLLATION-DEPENDENT TEXT PICK: Max()/Min() over a TEXT column is a sort, and the engines sort text differently -- MariaDB's utf8mb4 collations fold case, PostgreSQL (as CI runs it) orders by byte value, so MAX('abc','ABD') is 'ABD' on MariaDB and 'abc' on PostgreSQL. Flag a Max()/Min() on a text column (description, item_name, warehouse, cost_center, remarks, uom, mode_of_payment, operation) that is NOT functionally dependent on the group key: it is a live MariaDB-vs-PostgreSQL divergence, not the arbitrary-pick preservation the wrap is usually justified as. Do NOT flag it when the column comes from a master joined ON the grouped key (then it is single-valued and collation is irrelevant). Fix by taking a representative row instead of sorting text. A local macOS PostgreSQL agrees with MariaDB here and gives a false all-clear -- trust CI. (b) NULL-SKIPPING: Max/Min ignore NULLs, so Max() over a mostly-NULL discriminator deterministically prefers the non-NULL value where MariaDB could return NULL -- flag when 'no value' is a meaningful state (fallback gates like 'if x:', dict keys, status decisions). (c) FABRICATED ARITHMETIC: Sum(x) * Max(y) -- or Python arithmetic combining a Sum'd and a Max'd column from the same grouped query -- where y can vary within the group invents a value no row ever had and Max biases it upward; require per-row Sum(x*y) when it feeds validation, budgets, valuation, or GL/stock values. (d) WRONG BOUND: when the aggregated value has a semantic, the bound must be chosen deliberately (Min(schedule_date) for a 'required by' date, Min(idx) for first-line ordering, a qty-weighted average for a rate); a blind Max can understate urgency or overstate a figure. Heuristic: if switching Max<->Min would change the answer, the column is NOT functionally dependent and wrapping either is the wrong fix -- group by it, restructure, or pick a bound for a stated reason with a test. REFACTOR / CONVERSION FAITHFULNESS: a commit labeled a 'refactor' or a raw-frappe.db.sql->frappe.qb/ORM conversion is meant to preserve behaviour but easily does not, and the change slips past the static checker and a one-engine green run -- diff the WHERE/predicate, the JOIN/ON conditions and the resulting ROW SET, not just the SELECT shape. A conversion that silently widens or narrows the filter (e.g. a 'posting_datetime > X' bound gaining an OR (posting_datetime == X AND creation > args.creation) branch under a sql->qb refactor) changes the rows touched on BOTH engines and is a regression hiding under a refactor label; call it out and require a test even if it is a deliberate bug-fix. DO NOT FLAG these false positives: .like()/['like'] on a TEXT column (already ILIKE on PostgreSQL -- but DO flag it on a non-text/integer column, see above), raw ifnull/backticks/LOCATE/REGEXP/.regexp() inside frappe.db.sql (auto-translated by the framework -- but RLIKE/.rlike() is NOT translated, see above), or an ORDER BY..LIMIT 1 tie where adding a tiebreaker would change MariaDB's current pick. Full catalog with examples and portable fixes is in .github/POSTGRES_COMPATIBILITY.md.", "customContext": { "files": [ { diff --git a/CODEOWNERS b/CODEOWNERS index 804230ac8d0..645ff62343b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -7,6 +7,7 @@ erpnext/accounts/ @ruthra-kumar erpnext/assets/ @khushi8112 erpnext/regional @ruthra-kumar erpnext/selling @ruthra-kumar +banking/ @nikkothari22 erpnext/buying/ @rohitwaghchaure @mihir-kandoi erpnext/maintenance/ @rohitwaghchaure @mihir-kandoi diff --git a/banking/src/components/features/BankReconciliation/CompanySelector.tsx b/banking/src/components/features/BankReconciliation/CompanySelector.tsx index 5496ec9f851..7ebd8bc078f 100644 --- a/banking/src/components/features/BankReconciliation/CompanySelector.tsx +++ b/banking/src/components/features/BankReconciliation/CompanySelector.tsx @@ -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) => doc.doctype === ":Company").map((company: Record) => 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 + } + return ( +
- +
{% }); %} diff --git a/erpnext/manufacturing/doctype/production_plan/services/bom_explosion.py b/erpnext/manufacturing/doctype/production_plan/services/bom_explosion.py index 07503465451..217feb4c814 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/bom_explosion.py +++ b/erpnext/manufacturing/doctype/production_plan/services/bom_explosion.py @@ -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"), diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index 9e6d26bd963..ac689ba0a3f 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -533,7 +533,6 @@ def _adjust_required_qty_for_uom(row, required_qty): row["purchase_uom"], row["stock_uom"], row.item_code ) ) - required_qty = required_qty / row["conversion_factor"] if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"): required_qty = ceil(required_qty) @@ -560,10 +559,11 @@ def _material_request_item_row( or row.get("default_warehouse") or item_group_defaults.get("default_warehouse") ) + precision = frappe.get_precision("Material Request Plan Item", "quantity") return { "item_code": row.item_code, "item_name": row.item_name, - "quantity": required_qty / conversion_factor, + "quantity": flt(required_qty / conversion_factor, precision), "conversion_factor": conversion_factor, "required_bom_qty": row.get("qty"), "stock_uom": row.get("stock_uom"), @@ -640,7 +640,7 @@ def _add_remaining_purchase_request(item, new_mr_items, required_qty, consider_m if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"): required_qty = ceil(required_qty) - item["quantity"] = required_qty / item.get("conversion_factor") + item["quantity"] = flt(required_qty / item.get("conversion_factor"), precision) new_mr_items.append(item) diff --git a/erpnext/manufacturing/doctype/production_plan/services/sub_assembly_queries.py b/erpnext/manufacturing/doctype/production_plan/services/sub_assembly_queries.py index 134dee34a2e..ec1760976a4 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/sub_assembly_queries.py +++ b/erpnext/manufacturing/doctype/production_plan/services/sub_assembly_queries.py @@ -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"), diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 5917804c411..a7891b10be0 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1367,6 +1367,29 @@ class TestProductionPlan(ERPNextTestSuite): self.assertEqual(row.uom, "Nos") self.assertEqual(row.qty, 1) + def test_material_request_item_quantity_rounded_to_precision(self): + from erpnext.stock.doctype.item.test_item import make_item + + fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name + bom_item = make_item( + properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"} + ).name + + if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}): + doc = frappe.get_doc("Item", bom_item) + doc.append("uoms", {"uom": "Nos", "conversion_factor": 3}) + doc.save() + + make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC") + + pln = create_production_plan( + item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1, stock_uom="_Test UOM 1" + ) + + precision = frappe.get_precision("Material Request Plan Item", "quantity") + self.assertEqual(len(pln.mr_items), 1) + self.assertEqual(pln.mr_items[0].quantity, flt(10 / 3, precision)) + def test_material_request_for_sub_assembly_items(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom @@ -2252,6 +2275,40 @@ class TestProductionPlan(ERPNextTestSuite): self.assertEqual(row.get("uom"), "Nos") self.assertEqual(row.get("conversion_factor"), 10.0) + def test_remaining_purchase_qty_rounded_to_precision(self): + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name + bom_item = make_item( + properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"} + ).name + + store_warehouse = create_warehouse("Store Warehouse", company="_Test Company") + rm_warehouse = create_warehouse("RM Warehouse", company="_Test Company") + + make_stock_entry(item_code=bom_item, qty=4, target=store_warehouse, rate=100) + + if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}): + doc = frappe.get_doc("Item", bom_item) + doc.append("uoms", {"uom": "Nos", "conversion_factor": 3}) + doc.save() + + make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC") + + pln = create_production_plan( + item_code=fg_item, planned_qty=30, stock_uom="_Test UOM 1", do_not_submit=1 + ) + pln.for_warehouse = rm_warehouse + pln.ignore_existing_ordered_qty = 1 + items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": store_warehouse}]) + + rows_by_type = {row.get("material_request_type"): row for row in items} + self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4) + + precision = frappe.get_precision("Material Request Plan Item", "quantity") + self.assertEqual(rows_by_type["Purchase"].get("quantity"), flt(26 / 3, precision)) + def test_unreserve_qty_on_closing_of_pp(self): from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.utils import get_or_make_bin diff --git a/erpnext/manufacturing/doctype/work_order/services/reservation.py b/erpnext/manufacturing/doctype/work_order/services/reservation.py index f22470cf2d4..59419bbd579 100644 --- a/erpnext/manufacturing/doctype/work_order/services/reservation.py +++ b/erpnext/manufacturing/doctype/work_order/services/reservation.py @@ -546,10 +546,10 @@ class WorkOrderStockReservation: @frappe.whitelist() def make_stock_reservation_entries( - doc: str | Document, items: str | list | None = None, is_transfer: bool = True, notify: bool = False + doc: str | dict, items: str | list | None = None, is_transfer: bool = True, notify: bool = False ): """Whitelisted entry point: verify Work Order write access, then reserve stock.""" - if isinstance(doc, str): + if isinstance(doc, str | dict): doc = parse_json(doc) doc = frappe.get_doc("Work Order", doc.get("name")) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 82723df1067..427a89df811 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -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 @@ -3949,6 +4042,45 @@ class TestWorkOrder(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, transfer_entry.submit) + @ERPNextTestSuite.change_settings( + "Stock Settings", + {"enable_stock_reservation": 1, "allow_partial_reservation": 1}, + ) + def test_partial_reservation_records_full_voucher_qty(self): + # Regression: a short reservation must keep voucher_qty as the full requirement. + from erpnext.stock.doctype.stock_entry.stock_entry_utils import ( + make_stock_entry as make_stock_entry_test_record, + ) + + production_item = "Test Partial Reservation FG" + rm_item = "Test Partial Reservation RM" + source_warehouse = "Stores - _TC" + + make_item(production_item, {"is_stock_item": 1}) + make_item(rm_item, {"is_stock_item": 1}) + + make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item]) + + # Only 6 units on hand while the Work Order needs 10. + make_stock_entry_test_record(item_code=rm_item, target=source_warehouse, qty=6, basic_rate=100) + + wo = make_wo_order_test_record( + item=production_item, + qty=10, + reserve_stock=1, + source_warehouse=source_warehouse, + ) + + sre = frappe.get_all( + "Stock Reservation Entry", + filters={"voucher_no": wo.name, "docstatus": 1}, + fields=["voucher_qty", "reserved_qty", "status"], + ) + self.assertEqual(len(sre), 1) + self.assertEqual(sre[0].reserved_qty, 6) + self.assertEqual(sre[0].voucher_qty, 10) + self.assertEqual(sre[0].status, "Partially Reserved") + def test_auto_stock_reservation_for_batched_raw_material(self): from erpnext.stock.doctype.stock_entry.stock_entry_utils import ( make_stock_entry as make_stock_entry_test_record, diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 612c9794230..b540bb7aaa7 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -5,7 +5,7 @@ frappe.ui.form.on("Work Order", { setup: function (frm) { frm.custom_make_buttons = { "Stock Entry": "Start", - "Pick List": "Create Pick List", + "Pick List": "Pick List", "Job Card": "Create Job Card", }; @@ -818,13 +818,21 @@ erpnext.work_order = { if (pending_to_transfer && frm.doc.status != "Stopped") { frm.has_start_btn = true; - frm.add_custom_button(__("Create Pick List"), function () { - erpnext.work_order.create_pick_list(frm); - }); + frm.add_custom_button( + __("Pick List"), + function () { + erpnext.work_order.create_pick_list(frm); + }, + __("Create") + ); - frm.add_custom_button(__("Material Request"), function () { - erpnext.work_order.make_material_request(frm); - }); + frm.add_custom_button( + __("Material Request"), + function () { + erpnext.work_order.make_material_request(frm); + }, + __("Create") + ); var start_btn = frm.add_custom_button(__("Start"), function () { erpnext.work_order.make_se(frm, "Material Transfer for Manufacture"); @@ -861,7 +869,7 @@ erpnext.work_order = { frappe.set_route("Form", stock_entry.doctype, stock_entry.name); }); }, - __("Make") + __("Create") ); } } @@ -895,7 +903,7 @@ erpnext.work_order = { backflush_raw_materials_based_on ); }, - __("Make") + __("Create") ); } } diff --git a/erpnext/manufacturing/doctype/work_order/work_order_calendar.js b/erpnext/manufacturing/doctype/work_order/work_order_calendar.js index 90ce74ce232..9173212f941 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order_calendar.js +++ b/erpnext/manufacturing/doctype/work_order/work_order_calendar.js @@ -46,3 +46,60 @@ frappe.views.calendar["Work Order"] = { ], get_events_method: "frappe.desk.calendar.get_events", }; + +const WORK_ORDER_GANTT_COLORS = { + Draft: "red", + Stopped: "red", + "Not Started": "red", + "In Process": "orange", + Completed: "green", + "Stock Reserved": "blue", + "Stock Partially Reserved": "orange", + Cancelled: "gray", +}; + +if (!frappe.views.GanttView.prototype._work_order_status_colors) { + frappe.views.GanttView.prototype._work_order_status_colors = true; + + const prepare_tasks = frappe.views.GanttView.prototype.prepare_tasks; + frappe.views.GanttView.prototype.prepare_tasks = function () { + prepare_tasks.call(this); + if (this.doctype === "Work Order") { + set_work_order_bar_classes(this); + } + }; + + const set_colors = frappe.views.GanttView.prototype.set_colors; + frappe.views.GanttView.prototype.set_colors = function () { + set_colors.call(this); + if (this.doctype === "Work Order") { + set_work_order_bar_styles(this); + } + }; +} + +function set_work_order_bar_classes(view) { + view.tasks.forEach((task, idx) => { + const color = WORK_ORDER_GANTT_COLORS[view.data[idx].status]; + if (color) { + task.custom_class = "wo-" + color; + } + }); +} + +function set_work_order_bar_styles(view) { + const style = [...new Set(Object.values(WORK_ORDER_GANTT_COLORS))] + .map( + (color) => ` + .gantt .bar-wrapper.wo-${color} .bar { + fill: var(--${color}-300); + } + .gantt .bar-wrapper.wo-${color} .bar-progress { + fill: var(--${color}-300); + } + ` + ) + .join(""); + + view.$result.prepend(``); +} diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index f89e7700db6..6d3c184fdef 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -457,7 +457,7 @@ def get_workstations(**kwargs): d.color = color_map.get(d.status, "red") d.workstation_link = get_url_to_form("Workstation", d.name) if d.status != "Production": - d.status_image = d.off_status_image + d.status_image = frappe.utils.escape_html(d.off_status_image) d.workstation_off = "workstation-off" return data diff --git a/erpnext/manufacturing/doctype_settings_map/blanket_order_(standard)/blanket_order_(standard).json b/erpnext/manufacturing/doctype_settings_map/blanket_order.json similarity index 92% rename from erpnext/manufacturing/doctype_settings_map/blanket_order_(standard)/blanket_order_(standard).json rename to erpnext/manufacturing/doctype_settings_map/blanket_order.json index b0862892ac9..66f125688bc 100644 --- a/erpnext/manufacturing/doctype_settings_map/blanket_order_(standard)/blanket_order_(standard).json +++ b/erpnext/manufacturing/doctype_settings_map/blanket_order.json @@ -19,6 +19,6 @@ "modified": "2026-07-10 11:01:49.066530", "modified_by": "Administrator", "module": "Manufacturing", - "name": "Blanket Order (Standard)", + "name": "Blanket Order - Manufacturing", "owner": "Administrator" } diff --git a/erpnext/manufacturing/doctype_settings_map/bom_(standard)/bom_(standard).json b/erpnext/manufacturing/doctype_settings_map/bom.json similarity index 95% rename from erpnext/manufacturing/doctype_settings_map/bom_(standard)/bom_(standard).json rename to erpnext/manufacturing/doctype_settings_map/bom.json index 295bf681cd2..f05b1a55ff3 100644 --- a/erpnext/manufacturing/doctype_settings_map/bom_(standard)/bom_(standard).json +++ b/erpnext/manufacturing/doctype_settings_map/bom.json @@ -23,6 +23,6 @@ "modified": "2026-07-10 11:47:13.281237", "modified_by": "Administrator", "module": "Manufacturing", - "name": "BOM (Standard)", + "name": "BOM - Manufacturing", "owner": "Administrator" } diff --git a/erpnext/manufacturing/doctype_settings_map/production_plan_(standard)/production_plan_(standard).json b/erpnext/manufacturing/doctype_settings_map/production_plan.json similarity index 92% rename from erpnext/manufacturing/doctype_settings_map/production_plan_(standard)/production_plan_(standard).json rename to erpnext/manufacturing/doctype_settings_map/production_plan.json index c112ea5b631..ab1d1142344 100644 --- a/erpnext/manufacturing/doctype_settings_map/production_plan_(standard)/production_plan_(standard).json +++ b/erpnext/manufacturing/doctype_settings_map/production_plan.json @@ -19,6 +19,6 @@ "modified": "2026-07-10 11:31:40.252142", "modified_by": "Administrator", "module": "Manufacturing", - "name": "Production Plan (Standard)", + "name": "Production Plan - Manufacturing", "owner": "Administrator" } diff --git a/erpnext/manufacturing/doctype_settings_map/work_order_(standard)/work_order_(standard).json b/erpnext/manufacturing/doctype_settings_map/work_order.json similarity index 96% rename from erpnext/manufacturing/doctype_settings_map/work_order_(standard)/work_order_(standard).json rename to erpnext/manufacturing/doctype_settings_map/work_order.json index e8f2c48141b..4a3ac606267 100644 --- a/erpnext/manufacturing/doctype_settings_map/work_order_(standard)/work_order_(standard).json +++ b/erpnext/manufacturing/doctype_settings_map/work_order.json @@ -43,6 +43,6 @@ "modified": "2026-07-20 17:58:35.816693", "modified_by": "Administrator", "module": "Manufacturing", - "name": "Work Order (Standard)", + "name": "Work Order - Manufacturing", "owner": "Administrator" } diff --git a/erpnext/manufacturing/page/shop_floor/shop_floor.py b/erpnext/manufacturing/page/shop_floor/shop_floor.py index dd4bce405c0..0ef5b99613e 100644 --- a/erpnext/manufacturing/page/shop_floor/shop_floor.py +++ b/erpnext/manufacturing/page/shop_floor/shop_floor.py @@ -22,6 +22,7 @@ JOB_CARD_FIELDS = [ "total_completed_qty", "for_quantity", "process_loss_qty", + "stock_uom", "finished_good", "transferred_qty", "status", diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py index e787451e57b..03e93ba1072 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py @@ -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 diff --git a/erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py b/erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py index 592f577b936..3a76f697f58 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py +++ b/erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py @@ -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.""" diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 1a88ff095b5..f2c2a447817 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -505,3 +505,6 @@ erpnext.patches.v16_0.recalculate_bins_for_production_plan_items erpnext.patches.v16_0.rename_ar_ap_ageing_filter erpnext.patches.v16_0.fix_subcontracting_titles erpnext.patches.v16_0.move_warehouse_defaults_to_company +erpnext.patches.v16_0.backfill_repost_accounting_ledger_status +erpnext.patches.v16_0.merge_seeded_item_group_root +erpnext.patches.v16_0.set_stock_uom_in_job_card diff --git a/erpnext/patches/v14_0/clear_reconciliation_values_from_singles.py b/erpnext/patches/v14_0/clear_reconciliation_values_from_singles.py index c1f5b60a406..21b31e3bda9 100644 --- a/erpnext/patches/v14_0/clear_reconciliation_values_from_singles.py +++ b/erpnext/patches/v14_0/clear_reconciliation_values_from_singles.py @@ -1,3 +1,4 @@ +import frappe from frappe import qb @@ -13,5 +14,8 @@ def execute(): "Payment Reconciliation Allocation", ] for x in doctypes: + # child tables may not exist yet on sites where this pre-model-sync patch runs first + if not frappe.db.table_exists(x): + continue dt = qb.DocType(x) qb.from_(dt).delete().run() diff --git a/erpnext/patches/v16_0/backfill_repost_accounting_ledger_status.py b/erpnext/patches/v16_0/backfill_repost_accounting_ledger_status.py new file mode 100644 index 00000000000..f5a240a506c --- /dev/null +++ b/erpnext/patches/v16_0/backfill_repost_accounting_ledger_status.py @@ -0,0 +1,25 @@ +import frappe +from frappe.query_builder.functions import Coalesce + + +def execute(): + """Backfill the statuses of documents reposted before those fields existed. + + Without it they show up as drafts and are offered a `Start Reposting` button that would + repost vouchers which are already reposted. + """ + ral = frappe.qb.DocType("Repost Accounting Ledger") + items = frappe.qb.DocType("Repost Accounting Ledger Items") + + reposted = ( + frappe.qb.from_(ral).select(ral.name).where((ral.docstatus == 1) & (Coalesce(ral.status, "") == "")) + ) + frappe.qb.update(items).set(items.status, "Reposted").where(items.parent.isin(reposted)).run() + + for docstatus, status in ((1, "Completed"), (2, "Cancelled")): + ( + frappe.qb.update(ral) + .set(ral.status, status) + .where((ral.docstatus == docstatus) & (Coalesce(ral.status, "") == "")) + .run() + ) diff --git a/erpnext/patches/v16_0/merge_seeded_item_group_root.py b/erpnext/patches/v16_0/merge_seeded_item_group_root.py new file mode 100644 index 00000000000..95683fc96f0 --- /dev/null +++ b/erpnext/patches/v16_0/merge_seeded_item_group_root.py @@ -0,0 +1,23 @@ +import frappe +from frappe.utils.nestedset import get_root_of + +SEEDED_ROOT = "All Item Groups" + + +def execute(): + """Collapse the "All Item Groups" node seeded under a pre-existing root. + + Setup seeding always inserted "All Item Groups" as a parentless group. On a + site where another app had already created the root (under a translated + name), it was re-parented instead, leaving a second group-root holding the + standard Item Groups. + """ + root = get_root_of("Item Group") + if not root or root == SEEDED_ROOT: + return + + seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True) + if not seeded or not seeded.is_group or seeded.parent_item_group != root: + return + + frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False) diff --git a/erpnext/patches/v16_0/set_stock_uom_in_job_card.py b/erpnext/patches/v16_0/set_stock_uom_in_job_card.py new file mode 100644 index 00000000000..35abf69df05 --- /dev/null +++ b/erpnext/patches/v16_0/set_stock_uom_in_job_card.py @@ -0,0 +1,36 @@ +import frappe + + +def execute(): + job_cards = frappe.get_all( + "Job Card", + filters={"stock_uom": ("is", "not set")}, + fields=["name", "finished_good", "production_item"], + ) + + if not job_cards: + return + + item_codes = {code for row in job_cards if (code := row.finished_good or row.production_item)} + if not item_codes: + return + + stock_uoms = dict( + frappe.get_all( + "Item", + filters={"name": ("in", list(item_codes))}, + fields=["name", "stock_uom"], + as_list=True, + ) + ) + + updates = {} + for row in job_cards: + stock_uom = stock_uoms.get(row.finished_good or row.production_item) + if stock_uom: + updates[row.name] = {"stock_uom": stock_uom} + + if not updates: + return + + frappe.db.bulk_update("Job Card", updates) diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 28ba6cebdef..a21baa74893 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -453,6 +453,17 @@ class TestTimesheet(ERPNextTestSuite): rate = get_timesheet_detail_rate(detail.name, timesheet.currency) self.assertEqual(rate, detail.billing_amount) + def test_title_follows_employee(self): + first = make_employee("_test_timesheet_title_one@example.com", company="_Test Company") + second = make_employee("_test_timesheet_title_two@example.com", company="_Test Company") + + timesheet = make_timesheet(first, simulate=True, do_not_submit=True) + self.assertEqual(timesheet.get_title(), frappe.db.get_value("Employee", first, "employee_name")) + + timesheet.employee = second + timesheet.save() + self.assertEqual(timesheet.get_title(), frappe.db.get_value("Employee", second, "employee_name")) + @staticmethod def _delete_if_exists(doctype, name): if frappe.db.exists(doctype, name): diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json index a703e6cd07f..ea50e074dbe 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.json +++ b/erpnext/projects/doctype/timesheet/timesheet.json @@ -49,7 +49,6 @@ "fields": [ { "allow_on_submit": 1, - "default": "{employee_name}", "fieldname": "title", "fieldtype": "Data", "hidden": 1, @@ -315,7 +314,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2026-04-08 12:43:30.658074", + "modified": "2026-07-30 11:04:12.882140", "modified_by": "Administrator", "module": "Projects", "name": "Timesheet", @@ -409,5 +408,5 @@ "sort_field": "creation", "sort_order": "ASC", "states": [], - "title_field": "title" + "title_field": "employee_name" } diff --git a/erpnext/projects/doctype_settings_map/timesheet_(standard)/timesheet_(standard).json b/erpnext/projects/doctype_settings_map/timesheet.json similarity index 94% rename from erpnext/projects/doctype_settings_map/timesheet_(standard)/timesheet_(standard).json rename to erpnext/projects/doctype_settings_map/timesheet.json index 2c6f7e8e2e1..c24a94a4341 100644 --- a/erpnext/projects/doctype_settings_map/timesheet_(standard)/timesheet_(standard).json +++ b/erpnext/projects/doctype_settings_map/timesheet.json @@ -19,6 +19,6 @@ "modified": "2026-07-10 10:37:54.591039", "modified_by": "Administrator", "module": "Projects", - "name": "Timesheet (Standard)", + "name": "Timesheet - Projects", "owner": "Administrator" } diff --git a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js index f9a311cbc7e..73cd29b2047 100644 --- a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js +++ b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js @@ -32,18 +32,7 @@ class BOMConfigurator { } bind_events() { - frappe.views.trees["BOM Configurator"].events = { - frm: this.frm, - add_item: this.add_item, - add_sub_assembly: this.add_sub_assembly, - set_query_for_workstation: this.set_query_for_workstation, - get_sub_assembly_modal_fields: this.get_sub_assembly_modal_fields, - convert_to_sub_assembly: this.convert_to_sub_assembly, - delete_node: this.delete_node, - edit_bom: this.edit_bom, - load_tree: this.load_tree, - set_default_qty: this.set_default_qty, - }; + frappe.views.trees["BOM Configurator"].events = this; } tree_options() { diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index dfdf2827cee..30dcfb0e83a 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -143,11 +143,26 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } } + get_item_fields_to_round() { + const [item] = this.frm.doc.items || []; + if (!item) { + return []; + } + + const do_not_round_fields = ["conversion_factor"]; + return frappe.meta + .get_fieldnames(item.doctype, item.parent, { + fieldtype: ["in", ["Currency", "Float"]], + }) + .filter((fieldname) => !do_not_round_fields.includes(fieldname)); + } + calculate_item_values() { var me = this; if (!this.discount_amount_applied) { + const fields_to_round = this.get_item_fields_to_round(); for (const item of this.frm.doc.items || []) { - frappe.model.round_floats_in(item); + frappe.model.round_floats_in(item, fields_to_round); item.net_rate = item.rate; item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty; diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 6d2de472ce0..5ecbd839156 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -18,10 +18,15 @@ erpnext.stock.qi_outgoing_purposes = [ "Subcontracting Delivery", "Disassemble", ]; +erpnext.stock.secondary_item_purposes = ["Manufacture", "Repack", "Disassemble"]; erpnext.stock.is_incoming_qi_purpose = (purpose) => purpose === "Manufacture" || erpnext.stock.qi_incoming_purposes.includes(purpose); erpnext.stock.row_requires_quality_inspection = (purpose, row) => { - if (row.secondary_item_type || row.is_legacy_scrap_item) return false; + if ( + erpnext.stock.secondary_item_purposes.includes(purpose) && + (row.secondary_item_type || row.is_legacy_scrap_item) + ) + return false; if (purpose === "Manufacture") return !!row.is_finished_item; if (erpnext.stock.qi_incoming_purposes.includes(purpose)) return !!row.t_warehouse; if (erpnext.stock.qi_outgoing_purposes.includes(purpose)) @@ -1797,7 +1802,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe let item = frappe.get_doc(cdt, cdn); item.conversion_factor = 1.0; if (item.stock_qty) { - item.conversion_factor = flt(item.stock_qty) / flt(item.qty); + item.conversion_factor = flt( + flt(item.stock_qty) / flt(item.qty), + precision("conversion_factor", item) + ); } refresh_field("conversion_factor", item.name, item.parentfield); diff --git a/erpnext/public/js/plant_floor_visual/visual_plant.js b/erpnext/public/js/plant_floor_visual/visual_plant.js index de85680a6db..e029e8427b1 100644 --- a/erpnext/public/js/plant_floor_visual/visual_plant.js +++ b/erpnext/public/js/plant_floor_visual/visual_plant.js @@ -176,7 +176,7 @@ class VisualPlantFloor { .find(".workstation-image-container") .append( `
${frappe.get_abbr( - data.name, + frappe.utils.escape_html(data.name), 2 )}
` ); diff --git a/erpnext/public/js/shop_floor/shop_floor.js b/erpnext/public/js/shop_floor/shop_floor.js index dc14f6d33da..4495c825169 100644 --- a/erpnext/public/js/shop_floor/shop_floor.js +++ b/erpnext/public/js/shop_floor/shop_floor.js @@ -356,7 +356,7 @@ class ShopFloor { // Hero image = the current operation's workstation. No item-image fallback — when the // workstation has no image uploaded we show its initials, never the product image. const image = wo.workstation_image - ? `` + ? `` : `${frappe.get_abbr(wo.workstation_name || item, 2)}`; const workstation_line = wo.workstation_name @@ -370,7 +370,9 @@ class ShopFloor { const wip_pct = Math.min(cint(wo.per_in_progress), 100 - done_pct); return ` -
+
${image}
@@ -378,7 +380,9 @@ class ShopFloor { ${workstation_line}
@@ -405,7 +409,7 @@ class ShopFloor { this.board_container .find(".sf-wo-card") .removeClass("sf-selected") - .filter(`[data-name="${name}"]`) + .filter(`[data-name="${$.escapeSelector(name)}"]`) .addClass("sf-selected"); // The detail pane reuses the operator rendering for a single work order. this.detail_container.html(` @@ -414,7 +418,9 @@ class ShopFloor { "Back" )} (Esc) ${frappe.utils.escape_html(name)} - ${__("Open")} + ${__( + "Open" + )}
`); @@ -786,16 +792,20 @@ class ShopFloor { pending = flt(jc.pending_qty); } + const qty_with_uom = (qty) => `${flt(qty)} ${jc.stock_uom || ""}`.trim(); + const fields = [ { fieldtype: "Float", - label: __("Qty to Manufacture"), + label: __("Qty to Manufacture in this Cycle"), fieldname: "for_quantity", reqd: 1, default: pending, + description: __("Completed, Pending and Process Loss quantities must add up to this."), change() { const d = me.session_dialog; d.set_value("completed_qty", d.get_value("for_quantity")); + d.set_value("pending_qty", 0); d.set_value("process_loss_qty", 0); }, }, @@ -807,8 +817,23 @@ class ShopFloor { default: pending, change() { const d = me.session_dialog; - const remaining = flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")); - if (remaining > 0 && remaining !== flt(d.get_value("pending_qty"))) { + const remaining = + flt(d.get_value("for_quantity")) - + flt(d.get_value("completed_qty")) - + flt(d.get_value("process_loss_qty")); + + if (remaining < 0) { + const max_completed_qty = + flt(d.get_value("for_quantity")) - flt(d.get_value("process_loss_qty")); + d.set_value("completed_qty", max_completed_qty); + frappe.throw( + __("Completed Quantity cannot be greater than {0}", [ + qty_with_uom(max_completed_qty), + ]) + ); + } + + if (remaining !== flt(d.get_value("pending_qty"))) { d.set_value("pending_qty", remaining); } }, @@ -818,13 +843,26 @@ class ShopFloor { label: __("Pending Quantity"), fieldname: "pending_qty", default: 0.0, + description: __("Qty left for a later cycle or for another job card."), change() { const d = me.session_dialog; const pl = flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")) - flt(d.get_value("pending_qty")); - if (pl >= 0 && pl !== flt(d.get_value("process_loss_qty"))) { + + if (pl < 0) { + d.set_value("pending_qty", 0); + frappe.throw( + __("Pending Quantity cannot be greater than {0}", [ + qty_with_uom( + flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")) + ), + ]) + ); + } + + if (pl !== flt(d.get_value("process_loss_qty"))) { d.set_value("process_loss_qty", pl); } }, @@ -834,13 +872,26 @@ class ShopFloor { label: __("Process Loss Quantity"), fieldname: "process_loss_qty", default: 0.0, + description: __("Qty scrapped in this cycle, nobody will produce it."), change() { const d = me.session_dialog; const remaining = flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")) - flt(d.get_value("process_loss_qty")); - if (remaining >= 0 && remaining !== flt(d.get_value("pending_qty"))) { + + if (remaining < 0) { + d.set_value("process_loss_qty", 0); + frappe.throw( + __("Process Loss Quantity cannot be greater than {0}", [ + qty_with_uom( + flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")) + ), + ]) + ); + } + + if (remaining !== flt(d.get_value("pending_qty"))) { d.set_value("pending_qty", remaining); } }, @@ -1190,7 +1241,7 @@ class ShopFloor { const pad = (n) => (n < 10 ? "0" + n : String(n)); const scope = $container || this.wrapper; - const timer = scope.find(`.mes-job-timer[data-job-card="${jc_name}"]`); + const timer = scope.find(`.mes-job-timer[data-job-card="${$.escapeSelector(jc_name)}"]`); timer.find(".h").text(pad(h)); timer.find(".m").text(pad(m)); timer.find(".s").text(pad(s)); diff --git a/erpnext/public/js/templates/item_selector.html b/erpnext/public/js/templates/item_selector.html index 86a15f49072..0839077f57d 100644 --- a/erpnext/public/js/templates/item_selector.html +++ b/erpnext/public/js/templates/item_selector.html @@ -1,17 +1,19 @@
{% for (var i=0; i < data.length; i++) { var item = data[i]; %} + {% const item_name = frappe.utils.escape_html(item.name); %} + {% const item_title = frappe.utils.escape_html(item.item_name || item.name); %} {% if (i % 4 === 0) { %}
{% } %} -
+
-
- {%= frappe.get_abbr(item.item_name || item.name) %} + {%= frappe.get_abbr(item_title) %} {% } %} {% if (item.image) { %} - {{item.item_name || item.name}} + {{ item_title }} {% } %}
diff --git a/erpnext/public/js/templates/visual_plant_floor_template.html b/erpnext/public/js/templates/visual_plant_floor_template.html index 9e4e867fcc2..96efcc3a1a8 100644 --- a/erpnext/public/js/templates/visual_plant_floor_template.html +++ b/erpnext/public/js/templates/visual_plant_floor_template.html @@ -1,5 +1,6 @@ {% $.each(workstations, (idx, row) => { %} -
+ {% const row_workstation_name = frappe.utils.escape_html(row.name); %} +
{{row.status}} @@ -10,12 +11,12 @@ {% if(row.status_image) { %} {% } else { %} -
{{frappe.get_abbr(row.name, 2)}}
+
{{frappe.get_abbr(row_workstation_name, 2)}}
{% } %}
- +
{{row.workstation_name}}
-{% }); %} \ No newline at end of file +{% }); %} diff --git a/erpnext/regional/italy/utils.py b/erpnext/regional/italy/utils.py index ce2c7450f70..1cbede63ef1 100644 --- a/erpnext/regional/italy/utils.py +++ b/erpnext/regional/italy/utils.py @@ -219,7 +219,7 @@ def append_row_as_charges(items, tax, reference_row, summary_data): # Preflight for successful e-invoice export. def sales_invoice_validate(doc): # Validate company - if doc.doctype != "Sales Invoice": + if doc.doctype != "Sales Invoice" or doc.is_opening == "Yes": return if not doc.company_address: @@ -303,7 +303,7 @@ def sales_invoice_validate(doc): # Ensure payment details are valid for e-invoice. def sales_invoice_on_submit(doc, method): # Validate payment details - if get_company_country(doc.company) not in [ + if doc.is_opening == "Yes" or get_company_country(doc.company) not in [ "Italy", "Italia", "Italian Republic", @@ -369,7 +369,7 @@ def generate_single_invoice(docname: str): # Delete e-invoice attachment on cancel. def sales_invoice_on_cancel(doc, method): - if get_company_country(doc.company) not in [ + if doc.is_opening == "Yes" or get_company_country(doc.company) not in [ "Italy", "Italia", "Italian Republic", diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 721ea466938..807f653fc27 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -445,33 +445,44 @@ class TestCustomer(ERPNextTestSuite): overdue = get_customer_overdue_amount("_Test Customer", "_Test Company") settings = frappe.get_single("Accounts Settings") - settings.enable_overdue_billing_threshold = 1 - settings.role_allowed_to_bypass_overdue_billing = None - settings.save() - set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100) + original_enable = settings.enable_overdue_billing_threshold + original_bypass_role = settings.role_allowed_to_bypass_overdue_billing + try: + settings.enable_overdue_billing_threshold = 1 + settings.role_allowed_to_bypass_overdue_billing = None + settings.save() + set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100) - # overdue is over the threshold and the user has no bypass role -> blocked - si = create_sales_invoice(do_not_submit=True) - self.assertRaises(frappe.ValidationError, si.submit) + # overdue is over the threshold and the user has no bypass role -> blocked + si = create_sales_invoice(do_not_submit=True) + self.assertRaises(frappe.ValidationError, si.submit) - # a user holding the bypass role can still submit - settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager" - settings.save() - si = create_sales_invoice(do_not_submit=True) - si.submit() - self.assertEqual(si.docstatus, 1) + # a user holding the bypass role can still submit + settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager" + settings.save() + si = create_sales_invoice(do_not_submit=True) + si.submit() + self.assertEqual(si.docstatus, 1) - # threshold still crossed, but the feature is off -> never blocked - settings.enable_overdue_billing_threshold = 0 - settings.role_allowed_to_bypass_overdue_billing = None - settings.save() - si = create_sales_invoice(do_not_submit=True) - si.submit() - self.assertEqual(si.docstatus, 1) + # threshold still crossed, but the feature is off -> never blocked + settings.enable_overdue_billing_threshold = 0 + settings.role_allowed_to_bypass_overdue_billing = None + settings.save() + si = create_sales_invoice(do_not_submit=True) + si.submit() + self.assertEqual(si.docstatus, 1) + finally: + settings.enable_overdue_billing_threshold = original_enable + settings.role_allowed_to_bypass_overdue_billing = original_bypass_role + settings.save() def test_overdue_billing_threshold_falls_back_to_customer_group(self): customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group") group = frappe.get_doc("Customer Group", customer_group) + customer = frappe.get_doc("Customer", "_Test Customer") + self._restore_credit_limits_after(group) + self._restore_credit_limits_after(customer) + group.credit_limits = [] group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000}) group.save() @@ -483,6 +494,22 @@ class TestCustomer(ERPNextTestSuite): set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000) self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 2000) + # a 0 on the customer inherits the group's limit + set_overdue_billing_threshold("_Test Customer", "_Test Company", 0) + self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000) + + def _restore_credit_limits_after(self, doc): + original = [row.as_dict(no_default_fields=True) for row in doc.credit_limits] + + def restore(): + fresh = frappe.get_doc(doc.doctype, doc.name) + fresh.credit_limits = [] + for row in original: + fresh.append("credit_limits", row) + fresh.save() + + self.addCleanup(restore) + def test_overdue_threshold_row_without_credit_limit(self): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice diff --git a/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.json b/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.json index 908251ea343..6db056bec04 100644 --- a/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.json +++ b/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.json @@ -38,6 +38,7 @@ "fieldname": "conversion_factor", "fieldtype": "Float", "label": "Conversion Factor", + "precision": "9", "read_only": 1 }, { @@ -106,7 +107,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-08-21 18:11:30.134073", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Selling", "name": "Delivery Schedule Item", diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.js b/erpnext/selling/doctype/product_bundle/product_bundle.js index 763fbcb56c9..4c73e8d8930 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.js +++ b/erpnext/selling/doctype/product_bundle/product_bundle.js @@ -9,6 +9,11 @@ frappe.ui.form.on("Product Bundle", { query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code", }; }); + frm.set_query("item_code", "items", () => { + return { + query: "erpnext.controllers.queries.item_query", + }; + }); // A submitted bundle is immutable. To change it, create a new version // (a fresh draft copied from this one) and submit that instead. diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index b1bca9e3e39..eeda99a64a6 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -154,6 +154,9 @@ class Quotation(SellingController): make_packing_list(self) + def after_insert(self): + self.carry_forward_communication() + def before_submit(self): self.set_has_alternative_item() @@ -301,7 +304,6 @@ class Quotation(SellingController): # update enquiry status self.update_opportunity("Quotation") self.update_lead() - self.carry_forward_communication() def on_cancel(self): if self.lost_reasons: diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 4ef5bdd928a..c70bddba2d5 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -216,6 +216,7 @@ "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", + "precision": "9", "print_hide": 1, "read_only": 1, "reqd": 1 @@ -729,7 +730,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-06-08 19:00:00.000000", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 944433b9e10..cec1114b1f3 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -691,6 +691,51 @@ class TestSalesOrder(ERPNextTestSuite): frappe.ValidationError, update_child_qty_rate, "Sales Order", trans_item, so.name ) + def test_update_child_removing_item_without_cancel_and_delete_perms(self): + for workflow_name in frappe.get_all( + "Workflow", filters={"document_type": "Sales Order", "is_active": 1}, pluck="name" + ): + workflow = frappe.get_doc("Workflow", workflow_name) + workflow.is_active = 0 + workflow.save() + + role = "_Test Sales Order Item Editor" + if not frappe.db.exists("Role", role): + frappe.get_doc({"doctype": "Role", "role_name": role, "desk_access": 1}).insert() + + frappe.permissions.add_permission("Sales Order", role, 0) + for right, value in { + "read": 1, + "write": 1, + "create": 1, + "submit": 1, + "cancel": 0, + "delete": 0, + }.items(): + frappe.permissions.update_permission_property("Sales Order", role, 0, right, value) + frappe.clear_cache() + + so = make_sales_order(**{"item_list": [{"item_code": "_Test Item", "qty": 5, "rate": 1000}]}) + trans_item = json.dumps( + [ + {"item_code": "_Test Item", "qty": 5, "rate": 1000, "docname": so.items[0].name}, + {"item_code": "_Test Item 2", "qty": 2, "rate": 500}, + ] + ) + update_child_qty_rate("Sales Order", trans_item, so.name) + so.reload() + self.assertEqual(len(so.items), 2) + + test_user = create_user("test_so_item_editor@example.com", role, "Accounts User", "Stock User") + trans_item = json.dumps( + [{"item_code": "_Test Item", "qty": 5, "rate": 1000, "docname": so.items[0].name}] + ) + with self.set_user(test_user.name): + update_child_qty_rate("Sales Order", trans_item, so.name) + + so.reload() + self.assertEqual(len(so.items), 1) + def test_update_child_qty_rate_with_workflow(self): from frappe.model.workflow import apply_workflow @@ -2023,6 +2068,41 @@ class TestSalesOrder(ERPNextTestSuite): sales_order.save() self.assertEqual(sales_order.taxes[0].tax_amount, 0) + def test_sales_order_with_shipping_rule_without_cost_center(self): + from erpnext import get_default_cost_center + + shipping_rule = frappe.get_doc( + { + "doctype": "Shipping Rule", + "label": "Shipping Rule Without Cost Center - Sales Order Test", + "shipping_rule_type": "Selling", + "company": "_Test Company", + "account": "_Test Account Shipping Charges - _TC", + "calculate_based_on": "Fixed", + "shipping_amount": 50, + } + ).insert() + sales_order = make_sales_order(do_not_save=True) + sales_order.shipping_rule = shipping_rule.name + company_cost_center = get_default_cost_center(sales_order.company) + + shipping_rule.apply(sales_order) + self.assertEqual(len(sales_order.taxes), 1) + self.assertIsNone(sales_order.taxes[0].cost_center) + + for cost_center in (None, "", company_cost_center): + sales_order.taxes[0].cost_center = cost_center + shipping_rule.apply(sales_order) + self.assertEqual(len(sales_order.taxes), 1) + self.assertEqual(sales_order.taxes[0].cost_center, cost_center) + + sales_order.taxes[0].cost_center = "" + sales_order.save() + sales_order.reload() + shipping_rule.apply(sales_order) + self.assertEqual(len(sales_order.taxes), 1) + self.assertEqual(sales_order.taxes[0].cost_center, "") + def test_sales_order_partial_advance_payment(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( create_payment_entry, diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index e6eecdbe120..adf38735fda 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -272,6 +272,7 @@ "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", + "precision": "9", "print_hide": 1, "read_only": 1, "reqd": 1 @@ -1065,7 +1066,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-07-29 12:05:00.000000", + "modified": "2026-08-07 18:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/doctype_settings_map/product_bundle_(standard)/product_bundle_(standard).json b/erpnext/selling/doctype_settings_map/product_bundle.json similarity index 91% rename from erpnext/selling/doctype_settings_map/product_bundle_(standard)/product_bundle_(standard).json rename to erpnext/selling/doctype_settings_map/product_bundle.json index 6866ca99a76..372db742222 100644 --- a/erpnext/selling/doctype_settings_map/product_bundle_(standard)/product_bundle_(standard).json +++ b/erpnext/selling/doctype_settings_map/product_bundle.json @@ -15,6 +15,6 @@ "modified": "2026-06-30 15:37:04.244159", "modified_by": "Administrator", "module": "Selling", - "name": "Product Bundle (Standard)", + "name": "Product Bundle - Selling", "owner": "Administrator" } diff --git a/erpnext/selling/doctype_settings_map/quotation_(standard)/quotation_(standard).json b/erpnext/selling/doctype_settings_map/quotation.json similarity index 94% rename from erpnext/selling/doctype_settings_map/quotation_(standard)/quotation_(standard).json rename to erpnext/selling/doctype_settings_map/quotation.json index 04182cbfa23..8b07b94a585 100644 --- a/erpnext/selling/doctype_settings_map/quotation_(standard)/quotation_(standard).json +++ b/erpnext/selling/doctype_settings_map/quotation.json @@ -19,6 +19,6 @@ "modified": "2026-07-20 15:34:21.043827", "modified_by": "Administrator", "module": "Selling", - "name": "Quotation (Standard)", + "name": "Quotation - Selling", "owner": "Administrator" } diff --git a/erpnext/selling/doctype_settings_map/sales_order_(standard)/sales_order_(standard).json b/erpnext/selling/doctype_settings_map/sales_order.json similarity index 98% rename from erpnext/selling/doctype_settings_map/sales_order_(standard)/sales_order_(standard).json rename to erpnext/selling/doctype_settings_map/sales_order.json index 66830c6a26f..91c35c69e5d 100644 --- a/erpnext/selling/doctype_settings_map/sales_order_(standard)/sales_order_(standard).json +++ b/erpnext/selling/doctype_settings_map/sales_order.json @@ -79,6 +79,6 @@ "modified": "2026-07-20 14:52:59.147895", "modified_by": "Administrator", "module": "Selling", - "name": "Sales Order (Standard)", + "name": "Sales Order - Selling", "owner": "Administrator" } diff --git a/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py b/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py index 7d7ea42209f..59374f054ad 100644 --- a/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py +++ b/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py @@ -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}, diff --git a/erpnext/selling/report/quotation_trends/test_quotation_trends.py b/erpnext/selling/report/quotation_trends/test_quotation_trends.py index 95ba6dd50bc..8128195f7ab 100644 --- a/erpnext/selling/report/quotation_trends/test_quotation_trends.py +++ b/erpnext/selling/report/quotation_trends/test_quotation_trends.py @@ -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 diff --git a/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py b/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py index 47a1c9679f8..5abe913491d 100644 --- a/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py @@ -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 diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 4dc23d4b1e6..f8c4205e574 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -309,6 +309,8 @@ erpnext.company.setup_queries = function (frm) { ["discount_allowed_account", { root_type: "Expense" }], ["discount_received_account", { root_type: "Income" }], ["exchange_gain_loss_account", { root_type: ["in", ["Expense", "Income"]] }], + ["exchange_gain_account", { root_type: ["in", ["Expense", "Income"]] }], + ["exchange_loss_account", { root_type: ["in", ["Expense", "Income"]] }], [ "unrealized_exchange_gain_loss_account", { root_type: ["in", ["Expense", "Income", "Equity", "Liability"]] }, diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index 84d1a161b87..5b85f1ff18e 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -65,6 +65,8 @@ "default_finance_book", "exchange_gain__loss_section", "exchange_gain_loss_account", + "exchange_gain_account", + "exchange_loss_account", "column_break_sttp", "unrealized_exchange_gain_loss_account", "round_off_section", @@ -397,6 +399,24 @@ "no_copy": 1, "options": "Account" }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "exchange_gain_account", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Exchange Gain Account", + "no_copy": 1, + "options": "Account" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "exchange_loss_account", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Exchange Loss Account", + "no_copy": 1, + "options": "Account" + }, { "depends_on": "eval:!doc.__islocal", "fieldname": "unrealized_exchange_gain_loss_account", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 34022033aec..441751bf984 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -103,7 +103,9 @@ class Company(NestedSet): enable_provisional_accounting_for_non_stock_items: DF.Check enable_stock_delivered_but_not_billed: DF.Check exception_budget_approver_role: DF.Link | None + exchange_gain_account: DF.Link | None exchange_gain_loss_account: DF.Link | None + exchange_loss_account: DF.Link | None existing_company: DF.Link | None expenses_added_to_stock_account: DF.Link | None expenses_added_to_stock_contra_account: DF.Link | None @@ -369,6 +371,8 @@ class Company(NestedSet): ["Default Payment Discount Account", "default_discount_account"], ["Unrealized Profit / Loss Account", "unrealized_profit_loss_account"], ["Exchange Gain / Loss Account", "exchange_gain_loss_account"], + ["Exchange Gain Account", "exchange_gain_account"], + ["Exchange Loss Account", "exchange_loss_account"], ["Unrealized Exchange Gain / Loss Account", "unrealized_exchange_gain_loss_account"], ["Round Off Account", "round_off_account"], ["Default Deferred Revenue Account", "default_deferred_revenue_account"], @@ -792,6 +796,20 @@ class Company(NestedSet): self.db_set("exchange_gain_loss_account", exchange_gain_loss_acct) + if not self.exchange_gain_account: + exchange_gain_acct = frappe.db.get_value( + "Account", {"account_name": _("Exchange Gain"), "company": self.name, "is_group": 0} + ) + + self.db_set("exchange_gain_account", exchange_gain_acct) + + if not self.exchange_loss_account: + exchange_loss_acct = frappe.db.get_value( + "Account", {"account_name": _("Exchange Loss"), "company": self.name, "is_group": 0} + ) + + self.db_set("exchange_loss_account", exchange_loss_acct) + if not self.disposal_account: disposal_acct = frappe.db.get_value( "Account", diff --git a/erpnext/setup/doctype/item_group/test_item_group.py b/erpnext/setup/doctype/item_group/test_item_group.py index a37ab55d508..f44567ee021 100644 --- a/erpnext/setup/doctype/item_group/test_item_group.py +++ b/erpnext/setup/doctype/item_group/test_item_group.py @@ -1,6 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from unittest.mock import patch + import frappe from frappe.query_builder.functions import Max from frappe.utils.nestedset import ( @@ -14,6 +16,8 @@ from frappe.utils.nestedset import ( from erpnext.tests.utils import ERPNextTestSuite +TRANSLATED_ROOT = "Todos os Grupos de Itens" + class TestItemGroup(ERPNextTestSuite): def setUp(self): @@ -204,6 +208,54 @@ class TestItemGroup(ERPNextTestSuite): merge=True, ) + def test_preset_records_use_existing_root(self): + from erpnext.setup.setup_wizard.operations import install_fixtures + + with patch.object(install_fixtures, "get_root_of", return_value=TRANSLATED_ROOT): + records = [ + r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" + ] + + root_record, *child_records = records + self.assertEqual(root_record["item_group_name"], TRANSLATED_ROOT) + self.assertTrue(root_record["__condition"]()) + self.assertEqual({r["parent_item_group"] for r in child_records}, {TRANSLATED_ROOT}) + + with patch.object(install_fixtures, "get_root_of", return_value="All Item Groups"): + root_record = next( + r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" + ) + self.assertFalse(root_record["__condition"]()) + + def test_patch_merges_seeded_root_into_existing_root(self): + from erpnext.patches.v16_0.merge_seeded_item_group_root import execute + + self._nest_root_under(TRANSLATED_ROOT) + self.assertEqual( + frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT + ) + + execute() + + self.assertFalse(frappe.db.exists("Item Group", "All Item Groups")) + self.assertEqual( + frappe.get_all("Item Group", filters={"parent_item_group": ("is", "not set")}, pluck="name"), + [TRANSLATED_ROOT], + ) + self.assertEqual( + frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT + ) + self.test_basic_tree() + + def _nest_root_under(self, new_root): + """Recreate the tree left behind by seeding a root under a pre-existing one.""" + frappe.get_doc({"doctype": "Item Group", "item_group_name": new_root, "is_group": 1}).insert() + + ig = frappe.qb.DocType("Item Group") + frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run() + frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run() + rebuild_tree("Item Group") + def _move_it_back(self): group_b = frappe.get_doc("Item Group", "_Test Item Group B") group_b.parent_item_group = "All Item Groups" diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 346b1834032..c2842c02c49 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -345,22 +345,28 @@ def update_pegged_currencies(): def set_default_print_formats(): + # For each doctype, prefer the newer builder-made "Modern with Images" format, + # falling back to the older "with Item Image" format if it isn't present. default_map = { - "Sales Order": "Sales Order with Item Image", - "Sales Invoice": "Sales Invoice with Item Image", - "Delivery Note": "Delivery Note with Item Image", - "Purchase Order": "Purchase Order with Item Image", - "Purchase Invoice": "Purchase Invoice with Item Image", - "POS Invoice": "POS Invoice with Item Image", - "Quotation": "Quotation with Item Image", - "Request for Quotation": "Request for Quotation with Item Image", + "Sales Order": ["Sales Order Modern with Images", "Sales Order with Item Image"], + "Sales Invoice": ["Sales Invoice Modern with Images", "Sales Invoice with Item Image"], + "Delivery Note": ["Delivery Note Modern with Images", "Delivery Note with Item Image"], + "Purchase Order": ["Purchase Order Modern with Images", "Purchase Order with Item Image"], + "Purchase Invoice": ["Purchase Invoice Modern with Images", "Purchase Invoice with Item Image"], + "POS Invoice": ["POS Invoice Modern with Images", "POS Invoice with Item Image"], + "Quotation": ["Quotation Modern with Images", "Quotation with Item Image"], + "Request for Quotation": [ + "Request for Quotation Modern with Images", + "Request for Quotation with Item Image", + ], } - for doctype, print_format in default_map.items(): + for doctype, print_formats in default_map.items(): if frappe.get_meta(doctype).default_print_format: continue - if not frappe.db.exists("Print Format", print_format): + print_format = next((pf for pf in print_formats if frappe.db.exists("Print Format", pf)), None) + if not print_format: continue frappe.make_property_setter( diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 107e4efebfb..42821707035 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -12,6 +12,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import ( ) from frappe.desk.page.setup_wizard.setup_wizard import make_records from frappe.utils import cstr, getdate +from frappe.utils.nestedset import get_root_of from erpnext.accounts.doctype.account.account import RootNotEditable from erpnext.regional.address_template.setup import set_up_address_templates @@ -24,46 +25,48 @@ def read_lines(filename: str) -> list[str]: def get_preset_records(country=None): + root_item_group = get_root_of("Item Group") or _("All Item Groups") records = [ # ensure at least an empty Address Template exists for this Country {"doctype": "Address Template", "country": country}, # item group { "doctype": "Item Group", - "item_group_name": _("All Item Groups"), + "item_group_name": root_item_group, "is_group": 1, "parent_item_group": "", + "__condition": lambda: not frappe.db.exists("Item Group", root_item_group), }, { "doctype": "Item Group", "item_group_name": _("Products"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, "show_in_website": 1, }, { "doctype": "Item Group", "item_group_name": _("Raw Material"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, { "doctype": "Item Group", "item_group_name": _("Services"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, { "doctype": "Item Group", "item_group_name": _("Sub Assemblies"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, { "doctype": "Item Group", "item_group_name": _("Consumable"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, # Stock Entry Type { diff --git a/erpnext/stock/__init__.py b/erpnext/stock/__init__.py index 37b0f88159c..4587fc857f5 100644 --- a/erpnext/stock/__init__.py +++ b/erpnext/stock/__init__.py @@ -80,10 +80,13 @@ def get_warehouse_account(warehouse, warehouse_account=None): account = get_company_default_inventory_account(warehouse.company) if not account and warehouse.company: - account = frappe.db.get_value( - "Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, "name" + inventory_accounts = frappe.get_all( + "Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, pluck="name" ) + if len(inventory_accounts) == 1: + account = inventory_accounts[0] + if not account and warehouse.company and not warehouse.is_group: frappe.throw( _("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format( diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 18affbab66e..19f00417f33 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -145,6 +145,9 @@ class DeprecatedBatchNoValuation: if self.sle.name: conditions &= sle.name != self.sle.name + if getattr(self, "stock_closing_from_datetime", None): + conditions &= sle.posting_datetime >= self.stock_closing_from_datetime + # MariaDB carries a row lock on the grouped query below; on postgres the caller # (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse). query = ( diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index b35752bec5b..1125d2cc226 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -258,6 +258,7 @@ "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", + "precision": "9", "print_hide": 1, "read_only": 1, "reqd": 1 @@ -992,7 +993,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-07-29 12:30:00.000000", + "modified": "2026-08-07 18:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 95e0967ca71..0d3b549e5f4 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -860,7 +860,17 @@ class Item(Document): frappe.throw(_("Item {0} is not a template item.").format(frappe.bold(self.variant_of))) if based_on == "Item Attribute": + previous_doc = self.get_doc_before_save() + saved_attributes = ( + {(row.attribute, row.attribute_value) for row in previous_doc.attributes} + if previous_doc + else set() + ) + for d in self.attributes: + if (d.attribute, d.attribute_value) in saved_attributes: + continue + if not frappe.db.exists( "Item Variant Attribute", {"attribute": d.attribute, "parent": self.variant_of} ): @@ -1498,7 +1508,7 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None): "UOM Conversion Factor", {"to_uom": from_uom, "from_uom": to_uom}, ["value"], as_dict=1 ) if inverse_match: - return 1 / inverse_match.value + return flt(1 / inverse_match.value, frappe.get_precision("UOM Conversion Factor", "value")) # This attempts to try and get conversion from intermediate UOM. # case: @@ -1518,7 +1528,7 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None): ) if intermediate_match: - return intermediate_match[0].value + return flt(intermediate_match[0].value, frappe.get_precision("UOM Conversion Factor", "value")) @frappe.whitelist() diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 4080d65aab4..6aec921390b 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -423,6 +423,45 @@ class TestItem(ERPNextTestSuite): self.assertRaises(InvalidItemAttributeValueError, attribute.save) + def test_disabled_attribute_blocks_only_attribute_changes(self): + frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template", force=1) + frappe.delete_doc_if_exists("Item Attribute", "_Test Disabled Size", force=1) + + attribute = frappe.get_doc( + { + "doctype": "Item Attribute", + "attribute_name": "_Test Disabled Size", + "item_attribute_values": [ + {"attribute_value": "Large", "abbr": "L"}, + {"attribute_value": "Small", "abbr": "S"}, + ], + } + ).insert() + + template = make_item( + "_Test Disabled Attribute Template", + { + "has_variants": 1, + "variant_based_on": "Item Attribute", + "attributes": [{"attribute": attribute.name}], + }, + ) + + variant = create_variant(template.name, {attribute.name: "Large"}) + variant.save() + + attribute.disabled = 1 + attribute.save() + + variant.reload() + variant.description = "Edited after the attribute was disabled" + variant.save() + + variant.reload() + variant.attributes[0].attribute_value = "Small" + self.assertRaises(frappe.ValidationError, variant.save) + def test_rename_attribute_value_updates_variants(self): frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index cc789bc1eca..bc776483eba 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -221,8 +221,10 @@ class TestLandedCostVoucher(ERPNextTestSuite): epi = is_perpetual_inventory_enabled(company_a) company_doc = frappe.get_doc("Company", company_a) + old_inventory_account = company_doc.default_inventory_account company_doc.enable_perpetual_inventory = 1 company_doc.stock_received_but_not_billed = srbnb + company_doc.default_inventory_account = "Stock In Hand - _TC" company_doc.save() pr = make_purchase_receipt( @@ -250,7 +252,11 @@ class TestLandedCostVoucher(ERPNextTestSuite): distribute_landed_cost_on_items(lcv) lcv.submit() - frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi) + frappe.db.set_value( + "Company", + company_a, + {"enable_perpetual_inventory": epi, "default_inventory_account": old_inventory_account}, + ) frappe.local.enable_perpetual_inventory = {} def test_landed_cost_voucher_for_zero_purchase_rate(self): diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 14def8afcd7..ea6fd98fdca 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -7,8 +7,12 @@ 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, getdate, nowdate +from frappe.utils import cint, comma_and, flt, get_link_to_form, getdate, nowdate +from erpnext.setup.doctype.brand.brand import get_brand_defaults +from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.item.item import get_item_defaults +from erpnext.stock.get_item_details import get_default_supplier from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( get_subcontracting_boms_for_finished_goods, ) @@ -21,6 +25,16 @@ def set_missing_values(source, target_doc): target_doc.run_method("calculate_taxes_and_totals") +def get_source_item_for_qty(item, qty): + """Copy of the source row whose pending quantity is the requested quantity.""" + source_item = frappe._dict(item.as_dict()) + source_item.ordered_qty = 0 + source_item.received_qty = 0 + source_item.stock_qty = flt(qty) * flt(item.conversion_factor) + + return source_item + + def update_item(obj, target, source_parent): target.conversion_factor = obj.conversion_factor @@ -52,17 +66,26 @@ def make_purchase_order( source_name: str, target_doc: str | dict | Document | None = None, args: dict | str | None = None ): if args is None: - args = {} + args = frappe.flags.args or {} args = frappe.parse_json(args) is_subcontracted = ( frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting" ) + requested_qty = args.get("requested_qty") or {} + def postprocess(source, target_doc): target_doc.is_subcontracted = is_subcontracted + if args.get("supplier"): + target_doc.supplier = args.get("supplier") set_missing_values(source, target_doc) + def update_requested_item(obj, target, source_parent): + if obj.name in requested_qty: + obj = get_source_item_for_qty(obj, requested_qty[obj.name]) + update_item(obj, target, source_parent) + def select_item(d): filtered_items = args.get("filtered_children", []) child_filter = d.name in filtered_items if filtered_items else True @@ -102,7 +125,7 @@ def make_purchase_order( "doctype": "Purchase Order Item", "field_map": generate_field_map(), "field_no_map": ["item_code", "item_name", "qty"] if is_subcontracted else [], - "postprocess": update_item, + "postprocess": update_requested_item, "condition": select_item, }, }, @@ -140,6 +163,119 @@ def make_request_for_quotation(source_name: str, target_doc: str | dict | Docume return doclist +def get_default_supplier_for_item(item_code: str, company: str) -> str | None: + return get_default_supplier( + frappe._dict(), + get_item_defaults(item_code, company), + get_item_group_defaults(item_code, company), + get_brand_defaults(item_code, company), + ) + + +@frappe.whitelist() +def get_item_default_suppliers(source_name: str, filtered_children: str | list | None = None) -> list[dict]: + """Pending items of the Material Request with their default supplier.""" + filtered_children = frappe.parse_json(filtered_children) if filtered_children else [] + + material_request = frappe.get_doc("Material Request", source_name) + material_request.check_permission("read") + + items = [] + for item in material_request.items: + if filtered_children and item.name not in filtered_children: + continue + + ordered_qty = flt(item.ordered_qty) or flt(item.received_qty) + if ordered_qty >= flt(item.stock_qty): + continue + + items.append( + { + "material_request_item": item.name, + "item_code": item.item_code, + "item_name": item.item_name, + "pending_qty": (flt(item.stock_qty) - ordered_qty) / (flt(item.conversion_factor) or 1), + "uom": item.uom, + "supplier": get_default_supplier_for_item(item.item_code, material_request.company), + } + ) + + return items + + +@frappe.whitelist(methods=["POST"]) +def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | list) -> list[str]: + """Create one draft Purchase Order per supplier for the given Material Request items.""" + item_suppliers = frappe.parse_json(item_suppliers) + if not item_suppliers: + frappe.throw(_("Select at least one Item")) + + pending_items = { + d["material_request_item"]: frappe._dict(d) for d in get_item_default_suppliers(source_name) + } + + items_by_supplier = {} + requested_items = set() + for row in item_suppliers: + row = frappe._dict(row) + pending = pending_items.get(row.material_request_item) or frappe._dict() + item_link = get_link_to_form("Item", row.item_code) + + if row.material_request_item in requested_items: + frappe.throw(_("Item {0} cannot be ordered more than once").format(item_link)) + + requested_items.add(row.material_request_item) + + if not row.supplier: + frappe.throw(_("Select a Supplier for Item {0}").format(item_link)) + + if flt(row.qty) <= 0 or flt(row.qty) > flt(pending.pending_qty): + pending_qty = frappe.format_value(flt(pending.pending_qty), "Float") + frappe.throw( + _("Quantity for Item {0} must be greater than zero and cannot exceed {1}").format( + item_link, frappe.bold(f"{pending_qty} {pending.uom or ''}".strip()) + ) + ) + + items_by_supplier.setdefault(row.supplier, {})[row.material_request_item] = flt(row.qty) + + purchase_orders = [] + is_rescheduled = False + for supplier, requested_qty in items_by_supplier.items(): + purchase_order = make_purchase_order( + source_name, + args={ + "supplier": supplier, + "filtered_children": list(requested_qty), + "requested_qty": requested_qty, + }, + ) + for item in purchase_order.items: + if not item.schedule_date: + item.schedule_date = nowdate() + is_rescheduled = True + + purchase_order.insert() + purchase_orders.append(purchase_order.name) + + if is_rescheduled: + frappe.toast( + _("{0} was set to today for items whose requested date has passed").format( + _(frappe.get_meta("Purchase Order Item").get_label("schedule_date")) + ), + indicator="orange", + ) + + if len(purchase_orders) > 1: + frappe.msgprint( + _("{0} created").format( + comma_and([get_link_to_form("Purchase Order", name) for name in purchase_orders]) + ) + ) + + return purchase_orders + + @frappe.whitelist() def get_items_based_on_default_supplier(supplier: str): supplier_items = [ @@ -152,51 +288,6 @@ def get_items_based_on_default_supplier(supplier: str): return supplier_items -@frappe.whitelist() -def make_purchase_order_based_on_supplier( - source_name: str, target_doc: str | dict | Document | None = None, args: dict | None = None -): - mr = source_name - - supplier_items = get_items_based_on_default_supplier(args.get("supplier")) - - def postprocess(source, target_doc): - target_doc.supplier = args.get("supplier") - if getdate(target_doc.schedule_date) < getdate(nowdate()): - target_doc.schedule_date = None - target_doc.set( - "items", - [d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0], - ) - - set_missing_values(source, target_doc) - - target_doc = get_mapped_doc( - "Material Request", - mr, - { - "Material Request": { - "doctype": "Purchase Order", - }, - "Material Request Item": { - "doctype": "Purchase Order Item", - "field_map": [ - ["name", "material_request_item"], - ["parent", "material_request"], - ["uom", "stock_uom"], - ["uom", "uom"], - ], - "postprocess": update_item, - "condition": lambda doc: doc.ordered_qty < doc.qty, - }, - }, - target_doc, - postprocess, - ) - - return target_doc - - @frappe.whitelist() def make_supplier_quotation(source_name: str, target_doc: str | dict | Document | None = None): def postprocess(source, target_doc): diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index b5a8c0560cd..530549f325c 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -412,13 +412,187 @@ frappe.ui.form.on("Material Request", { }, make_purchase_order: function (frm) { - frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order", - frm: frm, - run_link_triggers: true, + frappe.call({ + method: "erpnext.stock.doctype.material_request.mapper.get_item_default_suppliers", + args: { + source_name: frm.doc.name, + filtered_children: (frm.get_selected() || {}).items || [], + }, + freeze: true, + callback: function (r) { + const items = r.message || []; + const suppliers = new Set(items.map((item) => item.supplier || "")); + + if (suppliers.size > 1) { + frm.events.select_suppliers_for_items(frm, items); + return; + } + + frappe.model.open_mapped_doc({ + method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order", + frm: frm, + args: { supplier: items.length ? items[0].supplier : null }, + run_link_triggers: true, + }); + }, }); }, + select_suppliers_for_items: function (frm, items) { + const rows = items.map((item) => Object.assign({}, item, { qty: item.pending_qty, __checked: 1 })); + + const supplier_query = () => { + return { filters: { disabled: 0, prevent_pos: 0 } }; + }; + + const dialog = new frappe.ui.Dialog({ + title: __("Select Supplier for Items"), + size: "large", + fields: [ + { + fieldname: "supplier", + fieldtype: "Link", + options: "Supplier", + label: __("Set Supplier for All Items"), + get_query: supplier_query, + onchange: function () { + const supplier = dialog.get_value("supplier"); + if (!supplier) return; + + rows.forEach((row) => (row.supplier = supplier)); + dialog.fields_dict.items.grid.refresh(); + }, + }, + { fieldtype: "Column Break" }, + { fieldtype: "Section Break" }, + { + fieldname: "items", + fieldtype: "Table", + label: __("Items"), + cannot_add_rows: true, + cannot_delete_rows: true, + in_place_edit: true, + data: rows, + get_data: () => rows, + description: __("A separate Purchase Order is created for each Supplier."), + fields: [ + { + fieldtype: "Data", + fieldname: "material_request_item", + hidden: 1, + }, + { + fieldtype: "Link", + fieldname: "item_code", + options: "Item", + label: __("Item Code"), + read_only: 1, + in_list_view: 1, + columns: 3, + }, + { + fieldtype: "Data", + fieldname: "item_name", + label: __("Item Name"), + read_only: 1, + in_list_view: 1, + columns: 2, + }, + { + fieldtype: "Float", + fieldname: "pending_qty", + hidden: 1, + }, + { + fieldtype: "Float", + fieldname: "qty", + label: __("Quantity"), + reqd: 1, + in_list_view: 1, + columns: 1, + }, + { + fieldtype: "Link", + fieldname: "uom", + options: "UOM", + label: __("UOM"), + read_only: 1, + in_list_view: 1, + columns: 1, + }, + { + fieldtype: "Link", + fieldname: "supplier", + options: "Supplier", + label: __("Supplier"), + get_query: supplier_query, + reqd: 1, + in_list_view: 1, + columns: 3, + }, + ], + }, + ], + primary_action_label: __("Create"), + primary_action: async function (values) { + const item_suppliers = (values.items || []).filter((row) => row.__checked); + if (!item_suppliers.length) { + frappe.throw(__("Select at least one Item")); + } + + const item_link = (row) => + frappe.utils.get_form_link( + "Item", + row.item_code, + true, + frappe.utils.escape_html(row.item_code) + ); + + const missing_supplier = item_suppliers.find((row) => !row.supplier); + if (missing_supplier) { + frappe.throw(__("Select a Supplier for Item {0}", [item_link(missing_supplier)])); + } + + const invalid_qty = item_suppliers.find( + (row) => flt(row.qty) <= 0 || flt(row.qty) > flt(row.pending_qty) + ); + if (invalid_qty) { + const pending_qty = `${format_number(invalid_qty.pending_qty)} ${frappe.utils.escape_html( + invalid_qty.uom + )}`; + frappe.throw( + __("Quantity for Item {0} must be greater than zero and cannot exceed {1}", [ + item_link(invalid_qty), + `${pending_qty}`, + ]) + ); + } + + if (!(await erpnext.utils.confirm_if_drafts_exist(frm.doc, "Purchase Order"))) { + return; + } + + frappe.call({ + method: "erpnext.stock.doctype.material_request.mapper.make_purchase_orders_by_supplier", + args: { source_name: frm.doc.name, item_suppliers: item_suppliers }, + freeze: true, + callback: function (r) { + if (r.exc) return; + + dialog.hide(); + + const purchase_orders = r.message || []; + if (purchase_orders.length === 1) { + frappe.set_route("Form", "Purchase Order", purchase_orders[0]); + } + }, + }); + }, + }); + + dialog.show(); + }, + make_request_for_quotation: function (frm) { frappe.model.open_mapped_doc({ method: "erpnext.stock.doctype.material_request.mapper.make_request_for_quotation", diff --git a/erpnext/stock/doctype/material_request/material_request.json b/erpnext/stock/doctype/material_request/material_request.json index 2a455a1437c..1c6d7db5296 100644 --- a/erpnext/stock/doctype/material_request/material_request.json +++ b/erpnext/stock/doctype/material_request/material_request.json @@ -68,7 +68,6 @@ }, { "allow_on_submit": 1, - "default": "{material_request_type}", "fieldname": "title", "fieldtype": "Data", "hidden": 1, @@ -377,7 +376,7 @@ "idx": 70, "is_submittable": 1, "links": [], - "modified": "2026-03-09 17:15:30.124509", + "modified": "2026-07-30 11:04:31.517204", "modified_by": "Administrator", "module": "Stock", "name": "Material Request", diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index faec072513e..89f6d1b8cd1 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -6,7 +6,7 @@ import frappe -from frappe.utils import flt, today +from frappe.utils import add_days, flt, getdate, today from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.stock.doctype.item.test_item import create_item @@ -849,6 +849,28 @@ class TestMaterialRequest(ERPNextTestSuite): mr = frappe.get_doc("Material Request", mr.name) self.assertEqual(mr.per_ordered, 100) + def test_fractional_conversion_factor_for_purchase(self): + item = create_item("_Test Fractional Conversion Item", stock_uom="Kg", is_purchase_item=1) + conversion_factor = 0.453592292 + + mr = make_material_request( + item_code=item.name, + qty=1000, + uom="Pound", + conversion_factor=conversion_factor, + ) + mr.reload() + + self.assertEqual(mr.items[0].conversion_factor, conversion_factor) + + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.insert() + po.reload() + + self.assertEqual(po.items[0].conversion_factor, conversion_factor) + self.assertEqual(po.items[0].stock_qty, mr.items[0].stock_qty) + def test_customer_provided_parts_mr(self): create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0) existing_requested_qty = self._get_requested_qty("_Test Customer", "_Test Warehouse - _TC") @@ -1290,6 +1312,144 @@ class TestMaterialRequest(ERPNextTestSuite): self.assertIn(mr1.name, returned) self.assertIn(mr2.name, returned) + def test_get_item_default_suppliers(self): + from erpnext.stock.doctype.material_request.mapper import get_item_default_suppliers + + with_supplier = create_item_with_default_supplier("_Test MR Item Supplier A", "_Test Supplier") + without_supplier = create_item("_Test MR Item Without Supplier").name + + mr = make_material_request_for_items([with_supplier, without_supplier]) + items = get_item_default_suppliers(mr.name) + + self.assertEqual([d["item_code"] for d in items], [with_supplier, without_supplier]) + self.assertEqual(items[0]["supplier"], "_Test Supplier") + self.assertFalse(items[1]["supplier"]) + self.assertEqual(items[0]["pending_qty"], 10) + + def test_make_purchase_order_sets_supplier(self): + mr = make_material_request_for_items(["_Test Item"]) + po = make_purchase_order(mr.name, args={"supplier": "_Test Supplier"}) + + self.assertEqual(po.supplier, "_Test Supplier") + + def test_make_purchase_orders_by_supplier(self): + from erpnext.stock.doctype.material_request.mapper import make_purchase_orders_by_supplier + + item_codes = [create_item(f"_Test MR Grouped Item {index}").name for index in range(1, 4)] + mr = make_material_request_for_items(item_codes) + suppliers = ["_Test Supplier", "_Test Supplier", "_Test Supplier 1"] + + purchase_orders = make_purchase_orders_by_supplier( + mr.name, + [ + { + "material_request_item": item.name, + "item_code": item.item_code, + "qty": qty, + "supplier": supplier, + } + for item, supplier, qty in zip(mr.items, suppliers, [10, 10, 4], strict=True) + ], + ) + + self.assertEqual(len(purchase_orders), 2) + + first, second = (frappe.get_doc("Purchase Order", name) for name in purchase_orders) + self.assertEqual(first.supplier, "_Test Supplier") + self.assertEqual([d.item_code for d in first.items], item_codes[:2]) + self.assertEqual(second.supplier, "_Test Supplier 1") + self.assertEqual([d.item_code for d in second.items], item_codes[2:]) + self.assertEqual(second.items[0].qty, 4) + self.assertEqual(second.items[0].stock_qty, 4) + + def test_make_purchase_orders_by_supplier_sets_schedule_date(self): + from erpnext.stock.doctype.material_request.mapper import make_purchase_orders_by_supplier + + mr = make_material_request_for_items(["_Test Item"]) + frappe.db.set_value("Material Request Item", mr.items[0].name, "schedule_date", add_days(today(), -1)) + + purchase_orders = make_purchase_orders_by_supplier( + mr.name, + [ + { + "material_request_item": mr.items[0].name, + "item_code": "_Test Item", + "qty": 10, + "supplier": "_Test Supplier", + } + ], + ) + + po = frappe.get_doc("Purchase Order", purchase_orders[0]) + self.assertEqual(po.schedule_date, getdate(today())) + + alerts = [m for m in frappe.get_message_log() if m.get("alert")] + self.assertTrue(any("was set to today" in m.get("message") for m in alerts)) + + def test_make_purchase_orders_by_supplier_invalid_rows(self): + from erpnext.stock.doctype.material_request.mapper import make_purchase_orders_by_supplier + + mr = make_material_request_for_items(["_Test Item"]) + row = { + "material_request_item": mr.items[0].name, + "item_code": "_Test Item", + "qty": 10, + "supplier": "_Test Supplier", + } + + for invalid in [{"supplier": None}, {"qty": 0}, {"qty": -5}, {"qty": 11}]: + self.assertRaises( + frappe.ValidationError, make_purchase_orders_by_supplier, mr.name, [row | invalid] + ) + + self.assertRaises(frappe.ValidationError, make_purchase_orders_by_supplier, mr.name, []) + + self.assertRaises( + frappe.ValidationError, + make_purchase_orders_by_supplier, + mr.name, + [row, row | {"supplier": "_Test Supplier 1"}], + ) + + +def create_item_with_default_supplier(item_code, supplier): + item = create_item(item_code) + item.set("item_defaults", []) + item.append( + "item_defaults", + { + "company": "_Test Company", + "default_warehouse": "_Test Warehouse - _TC", + "default_supplier": supplier, + }, + ) + item.save() + + return item.name + + +def make_material_request_for_items(item_codes, **args): + args = frappe._dict(args) + mr = frappe.new_doc("Material Request") + mr.material_request_type = args.material_request_type or "Purchase" + mr.company = args.company or "_Test Company" + mr.schedule_date = today() + for item_code in item_codes: + mr.append( + "items", + { + "item_code": item_code, + "qty": args.qty or 10, + "schedule_date": today(), + "warehouse": args.warehouse or "_Test Warehouse - _TC", + }, + ) + + mr.insert() + mr.submit() + + return mr + def get_in_transit_warehouse(company): if not frappe.db.exists("Warehouse Type", "Transit"): diff --git a/erpnext/stock/doctype/material_request_item/material_request_item.json b/erpnext/stock/doctype/material_request_item/material_request_item.json index 5f38ffc7462..4e7027f11db 100644 --- a/erpnext/stock/doctype/material_request_item/material_request_item.json +++ b/erpnext/stock/doctype/material_request_item/material_request_item.json @@ -159,6 +159,7 @@ "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", + "precision": "9", "print_hide": 1, "reqd": 1 }, @@ -545,7 +546,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-01-06 20:47:27.317226", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Stock", "name": "Material Request Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index 0a8944580c3..62e70aa3c16 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -243,7 +243,8 @@ { "fieldname": "conversion_factor", "fieldtype": "Float", - "label": "Conversion Factor" + "label": "Conversion Factor", + "precision": "9" }, { "fieldname": "rate", @@ -349,7 +350,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-07-18 10:00:00.000000", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json index 50713795fd0..d391667cc17 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -126,6 +126,7 @@ "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", + "precision": "9", "read_only": 1 }, { @@ -307,7 +308,7 @@ ], "istable": 1, "links": [], - "modified": "2026-07-18 10:00:00.000000", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Stock", "name": "Pick List Item", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index c85f53ff1a8..91cee45603f 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -424,31 +424,10 @@ class PurchaseReceipt(BuyingController): row.received_qty, ) - def check_next_docstatus(self): - submit_rv = frappe.get_all( - "Purchase Invoice Item", - filters={"purchase_receipt": self.name, "docstatus": 1}, - fields=["parent"], - as_list=True, - limit=1, - ) - if submit_rv: - frappe.throw(_("Purchase Invoice {0} is already submitted").format(submit_rv[0][0])) - def on_cancel(self): super().on_cancel() self.check_for_on_hold_or_closed_status("Purchase Order", "purchase_order") - # Check if Purchase Invoice has been submitted against current Purchase Order - submitted = frappe.get_all( - "Purchase Invoice Item", - filters={"purchase_receipt": self.name, "docstatus": 1}, - fields=["parent"], - as_list=True, - limit=1, - ) - if submitted: - frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0])) self.update_prevdoc_status() self.update_billing_status() diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 9ecd02fedab..83dbeaa5886 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -3079,11 +3079,14 @@ class TestPurchaseReceipt(ERPNextTestSuite): old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company") frappe.local.enable_perpetual_inventory["_Test Company"] = 1 + old_inventory_account = frappe.db.get_value("Company", "_Test Company", "default_inventory_account") frappe.db.set_value( "Company", "_Test Company", - "stock_received_but_not_billed", - "Stock Received But Not Billed - _TC", + { + "stock_received_but_not_billed": "Stock Received But Not Billed - _TC", + "default_inventory_account": "Stock In Hand - _TC", + }, ) pr = make_purchase_receipt(qty=10, rate=1000, do_not_submit=1) @@ -3119,6 +3122,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): ) self.assertCountEqual(expected_gle, gl_entries) frappe.local.enable_perpetual_inventory["_Test Company"] = old_perpetual_inventory + frappe.db.set_value("Company", "_Test Company", "default_inventory_account", old_inventory_account) def test_purchase_receipt_with_use_serial_batch_field_for_rejected_qty(self): batch_item = make_item( @@ -5541,6 +5545,66 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]]) + def test_purchase_return_valuation_for_batchwise_valuation_batch(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + item_code = make_item( + "Test Purchase Return Batchwise Valn Item", + { + "is_stock_item": 1, + "has_batch_no": 1, + "batch_number_series": "BN-TPRBWV-.#####", + }, + ).name + + batch_no = "BN-TPRBWV-00001" + batch = frappe.new_doc("Batch").update({"batch_id": batch_no, "item": item_code}).insert() + self.assertEqual(batch.use_batchwise_valuation, 1) + + warehouse = "_Test Warehouse - _TC" + pr = make_purchase_receipt( + item_code=item_code, + qty=100, + rate=1000, + warehouse=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + ) + make_purchase_receipt( + item_code=item_code, + qty=100, + rate=400, + warehouse=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + ) + create_delivery_note( + item_code=item_code, + qty=100, + warehouse=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + ) + + return_pr = make_return_doc("Purchase Receipt", pr.name) + return_pr.submit() + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": return_pr.name, "is_cancelled": 0}, + ["stock_value_difference", "qty_after_transaction", "stock_value", "serial_and_batch_bundle"], + as_dict=True, + ) + self.assertEqual(flt(sle.qty_after_transaction), 0.0) + self.assertEqual(flt(sle.stock_value_difference, 2), -70000.0) + self.assertEqual(flt(sle.stock_value, 2), 0.0) + + rate = frappe.db.get_value( + "Serial and Batch Entry", {"parent": sle.serial_and_batch_bundle}, "incoming_rate" + ) + self.assertEqual(flt(rate, 2), 700.0) + def test_negative_stock_error_for_purchase_return(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -6131,17 +6195,31 @@ class TestPurchaseReceipt(ERPNextTestSuite): # already received against this PO line, excluding pr2 itself, is pr1's 4 self.assertEqual(pr2.get_already_received_qty(po.name, po_detail), 4.0) - def test_check_next_docstatus_blocks_with_submitted_invoice(self): - """check_next_docstatus must flag a submitted Purchase Invoice drawn from the receipt — - covers the converted child-table get_all (Purchase Invoice Item, docstatus=1).""" + def test_cancel_blocked_by_submitted_invoice_rolls_back(self): + """A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink + check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole + transaction has to roll back: the receipt stays submitted with no leaked ledger entries.""" pr = make_purchase_receipt() pi = make_purchase_invoice(pr.name) pi.insert() pi.submit() - with self.assertRaises(frappe.ValidationError) as cm: - pr.check_next_docstatus() - self.assertIn("is already submitted", str(cm.exception)) + pr.reload() + status_before = pr.status + sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}) + gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name}) + + frappe.db.savepoint("before_blocked_cancel") + with self.assertRaises(frappe.LinkExistsError) as cm: + pr.cancel() + self.assertIn(pi.name, str(cm.exception)) + frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback + + pr.reload() + self.assertEqual(pr.docstatus, 1) + self.assertEqual(pr.status, status_before) + self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before) + self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before) def create_asset_category_for_pr_test(): diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 2cebac95d93..996632d17a7 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -292,6 +292,7 @@ "label": "Conversion Factor", "oldfieldname": "conversion_factor", "oldfieldtype": "Currency", + "precision": "9", "print_hide": 1, "print_width": "100px", "reqd": 1, @@ -1154,7 +1155,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-07-29 12:30:00.000000", + "modified": "2026-08-07 18:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.json b/erpnext/stock/doctype/putaway_rule/putaway_rule.json index 90f486f2352..38ef543632a 100644 --- a/erpnext/stock/doctype/putaway_rule/putaway_rule.json +++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.json @@ -106,12 +106,13 @@ "fieldtype": "Float", "label": "Conversion Factor", "no_copy": 1, + "precision": "9", "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-07-08 09:19:26.711470", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Stock", "name": "Putaway Rule", diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index c1d2d831826..c00d1809868 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -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)) diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index 9445e5da94f..2ce6d4fe338 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -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 diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 6a2e835f670..4a29c693860 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -414,6 +414,13 @@ class SerialandBatchBundle(Document): valuation_method = get_valuation_method(self.item_code, self.company) + # An outward return must go out at the batch's current average rate for a + # batchwise valuation batch. The original receipt rate is only correct while + # the batch still holds stock at that rate; once other receipts have changed + # the average, removing at the original rate strands a residue in the batch + # value (negative when returning the costlier receipt). + batchwise_avg_rates = self.get_batchwise_return_avg_rates() + stock_queue = [] non_batchwise_batches = [] if not self.has_serial_no and valuation_method == "FIFO": @@ -447,6 +454,12 @@ class SerialandBatchBundle(Document): batches = sorted(list(valuation_details["batches"].keys())) valuation_rate = valuation_details["batches"].get(batches[cint(row.idx) - 1]) + # a batch with an available balance goes out at its current average rate (a + # valid 0.0 included); the original receipt rate applies only when there is + # no balance to average + if not row.serial_no and row.batch_no in batchwise_avg_rates: + valuation_rate = batchwise_avg_rates[row.batch_no] + row.incoming_rate = flt(valuation_rate) row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate) @@ -475,6 +488,43 @@ class SerialandBatchBundle(Document): elif self.type_of_transaction == "Inward": self.set_incoming_rate_for_inward_transaction(row, save, prev_sle=prev_sle) + def get_batchwise_return_avg_rates(self): + from erpnext.stock.utils import get_valuation_method + + if self.type_of_transaction != "Outward" or self.has_serial_no: + return {} + + batch_nos = [d.batch_no for d in self.entries if d.batch_no] + if not batch_nos: + return {} + + if get_valuation_method( + self.item_code, self.company + ) == "Moving Average" and frappe.db.get_single_value( + "Stock Settings", "do_not_use_batchwise_valuation" + ): + return {} + + batchwise_batches = frappe.get_all( + "Batch", + filters={"name": ("in", batch_nos), "use_batchwise_valuation": 1}, + pluck="name", + ) + if not batchwise_batches: + return {} + + # scoped to batchwise batches only, so BatchNoValuation's non-batchwise + # machinery never runs for them + sle = self.get_sle_for_outward_transaction() + sle.batch_nos = {batch_no: sle.batch_nos[batch_no] for batch_no in batchwise_batches} + sle.batchwise_valuation_batches = batchwise_batches + sn_obj = BatchNoValuation(sle=sle, item_code=self.item_code, warehouse=self.warehouse) + return { + batch_no: abs(flt(sn_obj.batch_avg_rate.get(batch_no))) + for batch_no in batchwise_batches + if flt(sn_obj.available_qty.get(batch_no)) + } + def validate_returned_serial_batch_no(self, return_against, row, original_inv_details): if frappe.flags.through_repost_item_valuation and not frappe.in_test: return diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 9110394444d..833b2c2ccab 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -4,7 +4,7 @@ import json import frappe -from frappe.utils import flt, nowtime, today +from frappe.utils import add_days, add_to_date, flt, nowtime, today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( @@ -1637,3 +1637,190 @@ class TestSerialandBatchBundleLogic(ERPNextTestSuite): self.assertNotIn(bundles[1], bundle_wise_serial_nos) self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no]) + + @ERPNextTestSuite.change_settings( + "Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1} + ) + def test_batchwise_valuation_for_same_posting_datetime_entries(self): + # an inward at a different rate and multiple outward rows with the same + # item and warehouse share the same posting datetime, the tie-breaking + # must include the same-timestamp entries which are already part of the + # ledger and must not let the outward rows count each other + item_code = make_item( + "Test Batchwise Same Posting Datetime Item 1", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBSPD-ITEM1-.#####", + "valuation_method": "FIFO", + }, + ).name + + warehouse = "_Test Warehouse - _TC" + + receipt = make_stock_entry( + item_code=item_code, + qty=10, + rate=100, + target=warehouse, + posting_date=add_days(today(), -5), + posting_time="12:00:00", + ) + + batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle) + self.assertTrue(frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation")) + + # same posting datetime as the outward rows below, at a different rate + make_stock_entry( + item_code=item_code, + qty=20, + rate=250, + target=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(today(), -3), + posting_time="12:00:00", + ) + + issue = make_stock_entry( + item_code=item_code, + qty=2, + source=warehouse, + posting_date=add_days(today(), -3), + posting_time="12:00:00", + do_not_save=True, + ) + + for qty in [3, 4]: + issue.append( + "items", + { + "item_code": item_code, + "s_warehouse": warehouse, + "qty": qty, + "conversion_factor": 1, + }, + ) + + issue.save() + issue.submit() + + # (10 * 100 + 20 * 250) / 30 = 200 + self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=200.0, balance_value=4200.0) + + # backdated receipt reposts the same posting datetime cluster + make_stock_entry( + item_code=item_code, + qty=10, + rate=100, + target=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(today(), -4), + posting_time="12:00:00", + ) + + # (20 * 100 + 20 * 250) / 40 = 175 + self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=175.0, balance_value=5425.0) + + @ERPNextTestSuite.change_settings( + "Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1} + ) + def test_batchwise_valuation_when_bundle_created_before_the_sle(self): + # a bundle can be created (drafted) much before / after its SLE, the + # tie-breaking for the same posting datetime entries must follow the + # SLE creation and not the bundle creation + item_code = make_item( + "Test Batchwise Same Posting Datetime Item 2", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBSPD-ITEM2-.#####", + "valuation_method": "FIFO", + }, + ).name + + warehouse = "_Test Warehouse - _TC" + + receipt = make_stock_entry( + item_code=item_code, + qty=10, + rate=100, + target=warehouse, + posting_date=add_days(today(), -5), + posting_time="12:00:00", + ) + + batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle) + + # inward at a different rate, same posting datetime as the outward below + inward = make_stock_entry( + item_code=item_code, + qty=10, + rate=200, + target=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(today(), -3), + posting_time="12:00:00", + ) + + outward = make_stock_entry( + item_code=item_code, + qty=10, + source=warehouse, + posting_date=add_days(today(), -3), + posting_time="12:00:00", + ) + + # simulate the inward's bundle drafted after the outward's SLE, the + # bundle creation timeline no longer matches the SLE creation timeline + outward_sle_creation = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": outward.name, "is_cancelled": 0}, + "creation", + ) + + frappe.db.set_value( + "Serial and Batch Bundle", + inward.items[0].serial_and_batch_bundle, + "creation", + add_to_date(outward_sle_creation, minutes=30), + update_modified=False, + ) + + repost = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Item and Warehouse", + "item_code": item_code, + "warehouse": warehouse, + "posting_date": add_days(today(), -6), + "posting_time": "00:00:00", + "allow_negative_stock": 1, + } + ) + + repost.submit() + + # (10 * 100 + 10 * 200) / 20 = 150, the inward precedes the outward as + # per the SLE creation even though its bundle was created afterwards + self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=150.0, balance_value=1500.0) + + def assert_batchwise_outgoing_rate(self, item_code, outgoing_rate, balance_value): + sl_entries = frappe.get_all( + "Stock Ledger Entry", + filters={"item_code": item_code, "is_cancelled": 0}, + fields=["actual_qty", "stock_value_difference", "stock_value"], + order_by="posting_datetime, creation", + ) + + for sle in sl_entries: + if sle.actual_qty > 0: + continue + + self.assertEqual(flt(sle.stock_value_difference, 2), flt(sle.actual_qty * outgoing_rate, 2)) + + self.assertEqual(flt(sl_entries[-1].stock_value, 2), flt(balance_value, 2)) diff --git a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py index 1f8450c87c4..1a60fea8d94 100644 --- a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py @@ -9,9 +9,51 @@ from frappe.desk.form.load import get_attachments from frappe.model.document import Document from frappe.utils import add_days, get_date_str, get_link_to_form, nowtime, parse_json from frappe.utils.background_jobs import enqueue +from frappe.utils.caching import request_cache from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions +SCOPE_FIELDS = ("warehouse", "item_code", "item_group", "warehouse_type") + + +def apply_unscoped_filters(filters): + meta = frappe.get_meta("Stock Closing Entry") + for fieldname in SCOPE_FIELDS: + if meta.has_field(fieldname): + filters[fieldname] = ("is", "not set") + + return filters + + +def get_closing_entry_for_closed_period(company): + closed_upto = frappe.db.get_value( + "Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}] + ) + if not closed_upto: + return None + + return _get_completed_closing_entry(company, str(closed_upto)) + + +@request_cache +def _get_completed_closing_entry(company, closed_upto): + filters = apply_unscoped_filters( + { + "company": company, + "docstatus": 1, + "status": "Completed", + "to_date": ("<=", closed_upto), + } + ) + + return frappe.db.get_value( + "Stock Closing Entry", + filters, + ["name", "to_date"], + order_by="to_date desc", + as_dict=True, + ) + class StockClosingEntry(Document): # begin: auto-generated types @@ -66,7 +108,7 @@ class StockClosingEntry(Document): ) ) - for fieldname in ["warehouse", "item_code", "item_group", "warehouse_type"]: + for fieldname in SCOPE_FIELDS: if self.get(fieldname): query = query.where(table[fieldname] == self.get(fieldname)) @@ -84,14 +126,30 @@ class StockClosingEntry(Document): self.enqueue_job() def on_cancel(self): + self.validate_closed_period_lock() self.set_status(save=True) self.remove_stock_closing() + def validate_closed_period_lock(self): + pcv = frappe.db.get_value( + "Period Closing Voucher", + {"company": self.company, "docstatus": 1, "period_end_date": (">=", self.to_date)}, + "name", + ) + + if pcv: + frappe.throw( + _( + "Stock Closing Entry {0} belongs to a closed accounting period. Cancel the Period Closing Voucher {1} first." + ).format(self.name, get_link_to_form("Period Closing Voucher", pcv)), + title=_("Closed Period"), + ) + def remove_stock_closing(self): table = frappe.qb.DocType("Stock Closing Balance") frappe.qb.from_(table).delete().where(table.stock_closing_entry == self.name).run() - @frappe.whitelist() + @frappe.whitelist(methods=["POST"]) def enqueue_job(self): self.db_set("status", "In Progress") enqueue(prepare_closing_stock_balance, name=self.name, queue="long", timeout=1500) @@ -101,8 +159,9 @@ class StockClosingEntry(Document): ).format(self.name) ) - @frappe.whitelist() + @frappe.whitelist(methods=["POST"]) def regenerate_closing_balance(self): + self.validate_closed_period_lock() self.remove_stock_closing() self.enqueue_job() diff --git a/erpnext/stock/doctype/stock_entry/services/disassemble.py b/erpnext/stock/doctype/stock_entry/services/disassemble.py index 3c94ea5771f..4c31de36c97 100644 --- a/erpnext/stock/doctype/stock_entry/services/disassemble.py +++ b/erpnext/stock/doctype/stock_entry/services/disassemble.py @@ -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() diff --git a/erpnext/stock/doctype/stock_entry/services/manufacturing.py b/erpnext/stock/doctype/stock_entry/services/manufacturing.py index 26655f41355..672a6ebd12a 100644 --- a/erpnext/stock/doctype/stock_entry/services/manufacturing.py +++ b/erpnext/stock/doctype/stock_entry/services/manufacturing.py @@ -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): diff --git a/erpnext/stock/doctype/stock_entry/services/material_transfer.py b/erpnext/stock/doctype/stock_entry/services/material_transfer.py index 1e97f8983b2..11933cf4c9d 100644 --- a/erpnext/stock/doctype/stock_entry/services/material_transfer.py +++ b/erpnext/stock/doctype/stock_entry/services/material_transfer.py @@ -226,9 +226,11 @@ class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry): first_row_by_item.setdefault(key, item) for key, transfer_qty in transfer_by_item.items(): - pending_qty = pending_by_item[key] + item = first_row_by_item[key] + precision = item.precision("qty") + transfer_qty = flt(transfer_qty, precision) + pending_qty = flt(pending_by_item[key], precision) if transfer_qty > pending_qty: - item = first_row_by_item[key] frappe.throw( _( "Row #{0}: Cannot transfer {1} {2} of Item {3}. " diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index da6228b0d9b..df3c4ab0d65 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -997,7 +997,10 @@ frappe.ui.form.on("Stock Entry Detail", { } if (frm.doc.purpose === "Receive from Customer") { - item.t_warehouse = frm.doc.items.find((item) => item.scio_detail).t_warehouse; + const scio_row = frm.doc.items.find((row) => row.scio_detail); + if (scio_row) { + item.t_warehouse = scio_row.t_warehouse; + } } }, set_basic_rate_manually(frm, cdt, cdn) { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b63df53bd32..b7417eb72e1 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -70,6 +70,15 @@ from erpnext.controllers.subcontracting_inward_controller import SubcontractingI form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"} +def is_costed_out_of_finished_item(row) -> bool: + """Whether the row takes its value out of the finished good instead of adding to it. + + A secondary item that is not linked to a BOM has no cost allocation of its own, so it is + valued the way the legacy scrap item was: its cost is deducted from the finished good. + """ + return bool(row.is_legacy_scrap_item or (row.secondary_item_type and not row.bom_secondary_item)) + + class StockEntry(StockController, SubcontractingInwardController): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -563,8 +572,11 @@ class StockEntry(StockController, SubcontractingInwardController): frappe.get_cached_value("BOM", self.bom_no, "cost_allocation_per") if self.bom_no else None ) + secondary_items_cost_basis = self.get_secondary_items_cost_basis(outgoing_items_cost) + zero_valuation_items = [] - for d in self.get("items"): + finished_items_last = sorted(self.get("items"), key=lambda row: cint(row.is_finished_item)) + for d in finished_items_last: if d.s_warehouse or d.set_basic_rate_manually: continue @@ -581,11 +593,26 @@ class StockEntry(StockController, SubcontractingInwardController): zero_valuation_items, bom_cost_allocation_per, has_consumption_basis, + secondary_items_cost_basis, ) if zero_valuation_items: self._notify_zero_valuation_rate(zero_valuation_items) + def get_secondary_items_cost_basis(self, outgoing_items_cost) -> float: + """The cost a BOM allocation splits: the consumed rows, or the entry that replaced them.""" + if outgoing_items_cost or self.purpose != "Manufacture" or not self.work_order: + return outgoing_items_cost + + settings = frappe.get_single("Manufacturing Settings") + if not (settings.material_consumption and settings.get_rm_cost_from_consumption_entry): + return outgoing_items_cost + + if not self.get_consumption_entries(): + return outgoing_items_cost + + return self._fetch_consumption_entry_cost() + def has_consumption_basis(self) -> bool: """Whether the cost of the consumed items is known, even when that cost is zero.""" if any(d.s_warehouse for d in self.get("items")): @@ -619,8 +646,9 @@ class StockEntry(StockController, SubcontractingInwardController): zero_valuation_items, bom_cost_allocation_per=None, has_consumption_basis=False, + secondary_items_cost_basis=0, ): - rate_derived_from_consumption = False + has_derived_rate = False if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer": d.basic_rate = 0.0 @@ -630,26 +658,25 @@ class StockEntry(StockController, SubcontractingInwardController): d.basic_rate = self.get_basic_rate_for_manufactured_item( d.transfer_qty, outgoing_items_cost, has_consumption_basis ) - rate_derived_from_consumption = has_consumption_basis + has_derived_rate = has_consumption_basis elif self.purpose == "Repack": d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost) # Repack rate comes from consumed source-warehouse rows, not consumption entries - rate_derived_from_consumption = any(item.s_warehouse for item in self.get("items")) + has_derived_rate = any(item.s_warehouse for item in self.get("items")) if self.bom_no: d.basic_rate *= bom_cost_allocation_per / 100 elif d.secondary_item_type and d.bom_secondary_item: - cost_allocation_per = frappe.get_value( - "BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per" + cost_allocation_per = flt( + frappe.get_value("BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per") ) - # Only recalculate when cost is actually allocated; otherwise preserve the - # user-entered rate (or fall through to get_valuation_rate below) - if cost_allocation_per and flt(d.transfer_qty): - d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty + if flt(d.transfer_qty): + d.basic_rate = (secondary_items_cost_basis * (cost_allocation_per / 100)) / d.transfer_qty + has_derived_rate = True - # A rate of zero derived from the consumed items is their actual cost, not a missing - # rate. Falling back to the item's valuation here would value free inputs as output. - if not d.basic_rate and not d.allow_zero_valuation_rate and not rate_derived_from_consumption: + # A rate of zero that was derived rather than left unset is a real cost. Falling back to + # the item's valuation here would value free inputs, or an unallocated row, as output. + if not d.basic_rate and not d.allow_zero_valuation_rate and not has_derived_rate: d.basic_rate = get_valuation_rate( d.item_code, d.t_warehouse, @@ -736,7 +763,9 @@ class StockEntry(StockController, SubcontractingInwardController): self, finished_item_qty, outgoing_items_cost=0, has_consumption_basis=False ) -> float: settings = frappe.get_single("Manufacturing Settings") - scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_legacy_scrap_item]) + scrap_items_cost = sum( + [flt(d.basic_amount) for d in self.get("items") if is_costed_out_of_finished_item(d)] + ) if settings.material_consumption: outgoing_items_cost = self._get_rm_cost_for_manufacture( @@ -901,7 +930,9 @@ class StockEntry(StockController, SubcontractingInwardController): for d in self.items: if d.t_warehouse and not d.s_warehouse: - if self.purpose == "Repack" or d.item_code == finished_item: + if d.secondary_item_type or d.is_legacy_scrap_item: + d.is_finished_item = 0 + elif self.purpose == "Repack" or d.item_code == finished_item: d.is_finished_item = 1 else: d.is_finished_item = 0 diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 74a11dd41ae..5a29b5c37cb 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -7,6 +7,7 @@ from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.exceptions import QualityInspectionRequiredError from erpnext.stock.doctype.item.test_item import ( create_item, make_item, @@ -906,6 +907,43 @@ class TestStockEntry(ERPNextTestSuite): fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item 2", stock_entry.get("items"))).amount self.assertEqual(fg_cost, flt(rm_cost + bom_operation_cost + work_order.additional_operating_cost, 2)) + @ERPNextTestSuite.change_settings("System Settings", {"float_precision": 3}) + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"backflush_raw_materials_based_on": "BOM"}) + def test_material_transfer_for_manufacture_qty_precision(self): + from erpnext.stock.doctype.stock_entry.services.material_transfer import ( + MaterialTransferForManufactureStockEntry, + ) + + work_order = frappe.new_doc("Work Order") + work_order.append( + "required_items", + { + "item_code": "_Test Item", + "required_qty": 33.876, + "transferred_qty": 33.875, + }, + ) + + stock_entry = frappe.new_doc("Stock Entry") + stock_entry.work_order = "Test Work Order" + stock_entry.append( + "items", + { + "item_code": "_Test Item", + "s_warehouse": "_Test Warehouse - _TC", + "qty": 0.001, + "uom": "Nos", + }, + ) + + service = MaterialTransferForManufactureStockEntry(stock_entry) + service._wo_doc = work_order + service._validate_no_excess_transfer() + + stock_entry.items[0].qty = 0.002 + with self.assertRaises(frappe.ValidationError): + service._validate_no_excess_transfer() + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1}) def test_work_order_manufacture_with_material_consumption(self): from erpnext.manufacturing.doctype.work_order.mapper import ( @@ -2691,6 +2729,254 @@ class TestStockEntry(ERPNextTestSuite): self.assertEqual(fg_sle.incoming_rate, 0) self.assertEqual(fg_sle.stock_value_difference, 0) + def test_secondary_item_type_does_not_waive_inspection_outside_manufacturing(self): + """A stray secondary item type must not let a QI-required item through a receipt.""" + item = make_item( + properties={ + "is_stock_item": 1, + "valuation_rate": 50, + "inspection_required_before_purchase": 1, + } + ).name + + def receipt(secondary_item_type): + se = frappe.new_doc("Stock Entry") + se.purpose = se.stock_entry_type = "Material Receipt" + se.company = "_Test Company" + se.inspection_required = 1 + se.append( + "items", + { + "item_code": item, + "t_warehouse": "_Test Warehouse - _TC", + "qty": 10, + "conversion_factor": 1, + "secondary_item_type": secondary_item_type, + }, + ) + return se + + self.assertRaises(QualityInspectionRequiredError, receipt("").submit) + self.assertRaises(QualityInspectionRequiredError, receipt("Scrap").submit) + + def test_manufacture_balances_secondary_item_added_without_a_bom(self): + """A secondary item with no BOM link is costed out of the finished good, as legacy scrap was.""" + rm_item = make_item(properties={"is_stock_item": 1}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + warehouse = "_Test Warehouse - _TC" + + make_stock_entry(item_code=rm_item, target=warehouse, qty=10, basic_rate=100) + + se = frappe.new_doc("Stock Entry") + se.purpose = se.stock_entry_type = "Manufacture" + se.company = "_Test Company" + se.append( + "items", {"item_code": rm_item, "s_warehouse": warehouse, "qty": 10, "conversion_factor": 1} + ) + se.append( + "items", + { + "item_code": fg_item, + "t_warehouse": warehouse, + "qty": 10, + "is_finished_item": 1, + "conversion_factor": 1, + }, + ) + se.append( + "items", + { + "item_code": scrap_item, + "t_warehouse": warehouse, + "qty": 5, + "secondary_item_type": "Scrap", + "conversion_factor": 1, + }, + ) + se.save() + + scrap_row = se.items[2] + self.assertEqual(flt(scrap_row.basic_rate), 20.0) + self.assertEqual(flt(scrap_row.basic_amount), 100.0) + + fg_row = se.items[1] + self.assertEqual(flt(fg_row.basic_rate), 90.0) + self.assertEqual(flt(fg_row.basic_amount), 900.0) + + self.assertEqual(flt(se.total_incoming_value), 1000.0) + self.assertEqual(flt(se.total_outgoing_value), 1000.0) + self.assertEqual(flt(se.value_difference), 0.0) + + def test_repack_allocates_cost_to_secondary_item(self): + """A Repack secondary item takes its own BOM share, not the finished good's.""" + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + warehouse = "_Test Warehouse - _TC" + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 10, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": rm_item, "qty": 10}) + bom.append( + "secondary_items", + { + "secondary_item_type": "Scrap", + "item_code": scrap_item, + "item_name": scrap_item, + "qty": 5, + "cost_allocation_per": 25, + "process_loss_per": 0, + }, + ) + bom.insert() + bom.submit() + self.assertEqual(flt(bom.cost_allocation_per), 75.0) + + make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100) + + se = frappe.new_doc("Stock Entry") + se.purpose = se.stock_entry_type = "Repack" + se.company = "_Test Company" + se.from_bom = 1 + se.bom_no = bom.name + se.fg_completed_qty = 10 + se.from_warehouse = warehouse + se.to_warehouse = warehouse + se.get_items() + se.save() + + fg_row = next(d for d in se.items if d.is_finished_item) + scrap_row = next(d for d in se.items if d.secondary_item_type) + + self.assertFalse(scrap_row.is_finished_item) + self.assertEqual(flt(scrap_row.basic_amount), 250.0) + self.assertEqual(flt(fg_row.basic_amount), 750.0) + + self.assertEqual(flt(se.total_incoming_value), 1000.0) + self.assertEqual(flt(se.total_outgoing_value), 1000.0) + self.assertEqual(flt(se.value_difference), 0.0) + + def test_secondary_item_with_zero_cost_allocation_carries_no_value(self): + """A BOM that allocates 0% to a secondary item gives the finished good everything.""" + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_from_wo, + ) + + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + warehouse = "_Test Warehouse - _TC" + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 10, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": rm_item, "qty": 10}) + bom.append( + "secondary_items", + { + "secondary_item_type": "Scrap", + "item_code": scrap_item, + "item_name": scrap_item, + "qty": 5, + "cost_allocation_per": 0, + "process_loss_per": 0, + }, + ) + bom.insert() + bom.submit() + self.assertEqual(flt(bom.cost_allocation_per), 100.0) + + make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100) + wo = make_wo_order_test_record( + production_item=fg_item, bom_no=bom.name, qty=10, skip_transfer=1, source_warehouse=warehouse + ) + + se = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10)) + se.save() + + scrap_row = next(d for d in se.items if d.secondary_item_type) + fg_row = next(d for d in se.items if d.is_finished_item) + + self.assertEqual(flt(scrap_row.basic_rate), 0.0) + self.assertEqual(flt(scrap_row.basic_amount), 0.0) + self.assertEqual(flt(fg_row.basic_amount), 1000.0) + self.assertEqual(flt(se.value_difference), 0.0) + + @ERPNextTestSuite.change_settings( + "Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1} + ) + def test_secondary_item_allocation_uses_consumption_entry_cost(self): + """A BOM allocation splits the consumption entry's cost, not an empty set of consumed rows.""" + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_from_wo, + ) + + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + warehouse = "_Test Warehouse - _TC" + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 10, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": rm_item, "qty": 10}) + bom.append( + "secondary_items", + { + "secondary_item_type": "Scrap", + "item_code": scrap_item, + "item_name": scrap_item, + "qty": 5, + "cost_allocation_per": 25, + "process_loss_per": 0, + }, + ) + bom.insert() + bom.submit() + + make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100) + wo = make_wo_order_test_record( + production_item=fg_item, bom_no=bom.name, qty=10, skip_transfer=1, source_warehouse=warehouse + ) + + consumption = frappe.get_doc( + make_stock_entry_from_wo(wo.name, "Material Consumption for Manufacture", 10) + ) + consumption.submit() + self.assertEqual(flt(consumption.total_outgoing_value), 1000.0) + + se = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10)) + se.save() + + scrap_row = next(d for d in se.items if d.secondary_item_type) + fg_row = next(d for d in se.items if d.is_finished_item) + + self.assertEqual(flt(fg_row.basic_amount), 750.0) + self.assertEqual(flt(scrap_row.basic_amount), 250.0) + self.assertEqual(flt(se.total_incoming_value), 1000.0) + def _make_wo_for_free_raw_material(self, rm_item, fg_item, bom_no): from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record from erpnext.manufacturing.doctype.work_order.work_order import ( diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 126daf21389..396d68487b6 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -257,6 +257,7 @@ "label": "Conversion Factor", "oldfieldname": "conversion_factor", "oldfieldtype": "Currency", + "precision": "9", "print_hide": 1, "reqd": 1 }, @@ -700,7 +701,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-07-18 10:00:00.000000", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index 3c9d56e8216..27a558cf198 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -837,7 +837,9 @@ def get_sre_reserved_qty_for_items_and_warehouses( .select( sre.item_code, sre.warehouse, - Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"), + Sum(sre.reserved_qty - sre.delivered_qty - sre.transferred_qty - sre.consumed_qty).as_( + "reserved_qty" + ), ) .where( (sre.docstatus == 1) @@ -1199,7 +1201,7 @@ class StockReservation: self.available_qty_to_reserve = self.get_available_qty_to_reserve(item_code, warehouse) if not self.available_qty_to_reserve: - self.throw_stock_not_exists_error(item.idx, item_code, warehouse) + self.throw_stock_not_exists_error(item.get("idx"), item_code, warehouse) self.qty_to_be_reserved = ( qty if self.available_qty_to_reserve >= qty else self.available_qty_to_reserve @@ -1216,7 +1218,7 @@ class StockReservation: sre.voucher_no = item.get("voucher_no") or self.doc.name sre.voucher_detail_no = item.get(child_doctype) or item.name or item.get("voucher_detail_no") sre.available_qty = self.available_qty_to_reserve - sre.voucher_qty = self.qty_to_be_reserved + sre.voucher_qty = qty sre.reserved_qty = self.qty_to_be_reserved sre.company = self.doc.company sre.stock_uom = item_details.stock_uom @@ -1259,13 +1261,16 @@ class StockReservation: ) def throw_stock_not_exists_error(self, idx, item_code, warehouse): - frappe.msgprint( - _("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format( + if idx: + msg = _("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format( idx, frappe.bold(item_code), frappe.bold(warehouse) - ), - title=_("Stock Reservation"), - indicator="orange", - ) + ) + else: + msg = _("Stock not available to reserve for the Item {0} in Warehouse {1}.").format( + frappe.bold(item_code), frappe.bold(warehouse) + ) + + frappe.msgprint(msg, title=_("Stock Reservation"), indicator="orange") def get_available_qty_to_reserve(self, item_code, warehouse, ignore_sre=None): available_qty = get_stock_balance(item_code, warehouse) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 5c2111b8f7c..3de206f9a18 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -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", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index f3890cc9dfe..cefb321c791 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -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: diff --git a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json index 2ab7f5e6600..90bf08be897 100644 --- a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json +++ b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json @@ -28,7 +28,8 @@ "label": "Conversion Factor", "non_negative": 1, "oldfieldname": "conversion_factor", - "oldfieldtype": "Float" + "oldfieldtype": "Float", + "precision": "9" }, { "fieldname": "column_break_nmeg", @@ -38,7 +39,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-06-11 23:02:54.800673", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Stock", "name": "UOM Conversion Detail", diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 87519bb8de8..3d67905741c 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -94,6 +94,44 @@ class TestWarehouse(ERPNextTestSuite): children = get_children("Warehouse", parent=company, company=company, is_root=True) self.assertTrue(any(wh["value"] == "_Test Warehouse - _TC" for wh in children)) + def test_inventory_account_fallback_with_multiple_stock_accounts(self): + from erpnext.stock import get_warehouse_account + + company = create_inventory_fallback_company() + frappe.db.set_value("Company", company, "default_inventory_account", None) + if frappe.db.exists("Account", "Extra Inventory Account - _TCIF"): + frappe.delete_doc("Account", "Extra Inventory Account - _TCIF") + + warehouse = frappe.get_doc("Warehouse", {"company": company, "is_group": 0}) + single_account = frappe.db.get_value( + "Account", {"account_type": "Stock", "is_group": 0, "company": company}, "name" + ) + self.assertEqual(get_warehouse_account(warehouse), single_account) + + create_account( + account_name="Extra Inventory Account", + parent_account=frappe.db.get_value("Account", single_account, "parent_account"), + account_type="Stock", + company=company, + ) + self.assertRaises(frappe.ValidationError, get_warehouse_account, warehouse) + + +def create_inventory_fallback_company(): + company = "_Test Company Inventory Fallback" + if not frappe.db.exists("Company", company): + frappe.get_doc( + { + "doctype": "Company", + "company_name": company, + "abbr": "_TCIF", + "default_currency": "INR", + "enable_perpetual_inventory": 0, + "country": "India", + } + ).insert(ignore_permissions=True) + return company + def create_warehouse(warehouse_name, properties=None, company=None): if not company: diff --git a/erpnext/stock/doctype_settings_map/batch_(standard)/batch_(standard).json b/erpnext/stock/doctype_settings_map/batch.json similarity index 94% rename from erpnext/stock/doctype_settings_map/batch_(standard)/batch_(standard).json rename to erpnext/stock/doctype_settings_map/batch.json index bd6c1ce7415..b8578795bdd 100644 --- a/erpnext/stock/doctype_settings_map/batch_(standard)/batch_(standard).json +++ b/erpnext/stock/doctype_settings_map/batch.json @@ -19,6 +19,6 @@ "modified": "2026-07-10 11:02:55.870708", "modified_by": "Administrator", "module": "Stock", - "name": "Batch (Standard)", + "name": "Batch - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/delivery_note_(standard)/delivery_note_(standard).json b/erpnext/stock/doctype_settings_map/delivery_note.json similarity index 96% rename from erpnext/stock/doctype_settings_map/delivery_note_(standard)/delivery_note_(standard).json rename to erpnext/stock/doctype_settings_map/delivery_note.json index fce18cba200..8eb5a46019b 100644 --- a/erpnext/stock/doctype_settings_map/delivery_note_(standard)/delivery_note_(standard).json +++ b/erpnext/stock/doctype_settings_map/delivery_note.json @@ -43,6 +43,6 @@ "modified": "2026-07-20 15:19:29.595043", "modified_by": "Administrator", "module": "Stock", - "name": "Delivery Note (Standard)", + "name": "Delivery Note - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/delivery_trip_(standard)/delivery_trip_(standard).json b/erpnext/stock/doctype_settings_map/delivery_trip.json similarity index 94% rename from erpnext/stock/doctype_settings_map/delivery_trip_(standard)/delivery_trip_(standard).json rename to erpnext/stock/doctype_settings_map/delivery_trip.json index 81bd01b7924..01ec982f1de 100644 --- a/erpnext/stock/doctype_settings_map/delivery_trip_(standard)/delivery_trip_(standard).json +++ b/erpnext/stock/doctype_settings_map/delivery_trip.json @@ -27,6 +27,6 @@ "modified": "2026-07-09 15:07:54.781814", "modified_by": "Administrator", "module": "Stock", - "name": "Delivery Trip (Standard)", + "name": "Delivery Trip - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/item_(standard)/item_(standard).json b/erpnext/stock/doctype_settings_map/item.json similarity index 97% rename from erpnext/stock/doctype_settings_map/item_(standard)/item_(standard).json rename to erpnext/stock/doctype_settings_map/item.json index b07d359d89b..177b2cf9683 100644 --- a/erpnext/stock/doctype_settings_map/item_(standard)/item_(standard).json +++ b/erpnext/stock/doctype_settings_map/item.json @@ -35,6 +35,6 @@ "modified": "2026-07-20 15:03:19.905964", "modified_by": "Administrator", "module": "Stock", - "name": "Item (Standard)", + "name": "Item - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/item_price_(standard)/item_price_(standard).json b/erpnext/stock/doctype_settings_map/item_price.json similarity index 94% rename from erpnext/stock/doctype_settings_map/item_price_(standard)/item_price_(standard).json rename to erpnext/stock/doctype_settings_map/item_price.json index c037d47035e..3f46b2a8942 100644 --- a/erpnext/stock/doctype_settings_map/item_price_(standard)/item_price_(standard).json +++ b/erpnext/stock/doctype_settings_map/item_price.json @@ -23,6 +23,6 @@ "modified": "2026-07-03 14:18:10.406964", "modified_by": "Administrator", "module": "Stock", - "name": "Item Price (Standard)", + "name": "Item Price - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/item_variant_(standard)/item_variant_(standard).json b/erpnext/stock/doctype_settings_map/item_variant.json similarity index 92% rename from erpnext/stock/doctype_settings_map/item_variant_(standard)/item_variant_(standard).json rename to erpnext/stock/doctype_settings_map/item_variant.json index 6d40b1da469..ff43ed4a698 100644 --- a/erpnext/stock/doctype_settings_map/item_variant_(standard)/item_variant_(standard).json +++ b/erpnext/stock/doctype_settings_map/item_variant.json @@ -15,6 +15,6 @@ "modified": "2026-07-09 13:46:50.401488", "modified_by": "Administrator", "module": "Stock", - "name": "Item Variant (Standard)", + "name": "Item Variant - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/material_request_(standard)/material_request_(standard).json b/erpnext/stock/doctype_settings_map/material_request.json similarity index 94% rename from erpnext/stock/doctype_settings_map/material_request_(standard)/material_request_(standard).json rename to erpnext/stock/doctype_settings_map/material_request.json index e42fb3e4558..6dbebbe4183 100644 --- a/erpnext/stock/doctype_settings_map/material_request_(standard)/material_request_(standard).json +++ b/erpnext/stock/doctype_settings_map/material_request.json @@ -27,6 +27,6 @@ "modified": "2026-07-20 16:04:40.139121", "modified_by": "Administrator", "module": "Stock", - "name": "Material Request (Standard)", + "name": "Material Request - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/pick_list_(standard)/pick_list_(standard).json b/erpnext/stock/doctype_settings_map/pick_list.json similarity index 94% rename from erpnext/stock/doctype_settings_map/pick_list_(standard)/pick_list_(standard).json rename to erpnext/stock/doctype_settings_map/pick_list.json index f3ae9ed32a2..8e0e652934c 100644 --- a/erpnext/stock/doctype_settings_map/pick_list_(standard)/pick_list_(standard).json +++ b/erpnext/stock/doctype_settings_map/pick_list.json @@ -19,6 +19,6 @@ "modified": "2026-07-20 16:05:15.546016", "modified_by": "Administrator", "module": "Stock", - "name": "Pick List (Standard)", + "name": "Pick List - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/purchase_receipt_(standard)/purchase_receipt_(standard).json b/erpnext/stock/doctype_settings_map/purchase_receipt.json similarity index 97% rename from erpnext/stock/doctype_settings_map/purchase_receipt_(standard)/purchase_receipt_(standard).json rename to erpnext/stock/doctype_settings_map/purchase_receipt.json index 16c2ce5161a..95c19f8a2cd 100644 --- a/erpnext/stock/doctype_settings_map/purchase_receipt_(standard)/purchase_receipt_(standard).json +++ b/erpnext/stock/doctype_settings_map/purchase_receipt.json @@ -59,6 +59,6 @@ "modified": "2026-07-20 16:02:40.647761", "modified_by": "Administrator", "module": "Stock", - "name": "Purchase Receipt (Standard)", + "name": "Purchase Receipt - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/repost_item_valuation_(standard)/repost_item_valuation_(standard).json b/erpnext/stock/doctype_settings_map/repost_item_valuation.json similarity index 96% rename from erpnext/stock/doctype_settings_map/repost_item_valuation_(standard)/repost_item_valuation_(standard).json rename to erpnext/stock/doctype_settings_map/repost_item_valuation.json index ac0f0580b0b..f54fad172da 100644 --- a/erpnext/stock/doctype_settings_map/repost_item_valuation_(standard)/repost_item_valuation_(standard).json +++ b/erpnext/stock/doctype_settings_map/repost_item_valuation.json @@ -43,6 +43,6 @@ "modified": "2026-07-09 11:45:29.543363", "modified_by": "Administrator", "module": "Stock", - "name": "Repost Item Valuation (Standard)", + "name": "Repost Item Valuation - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/stock_entry_(standard)/stock_entry_(standard).json b/erpnext/stock/doctype_settings_map/stock_entry.json similarity index 98% rename from erpnext/stock/doctype_settings_map/stock_entry_(standard)/stock_entry_(standard).json rename to erpnext/stock/doctype_settings_map/stock_entry.json index 6f62d24b497..1350e3e602e 100644 --- a/erpnext/stock/doctype_settings_map/stock_entry_(standard)/stock_entry_(standard).json +++ b/erpnext/stock/doctype_settings_map/stock_entry.json @@ -67,6 +67,6 @@ "modified": "2026-07-20 17:43:38.321292", "modified_by": "Administrator", "module": "Stock", - "name": "Stock Entry (Standard)", + "name": "Stock Entry - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/stock_ledger_entry_(standard)/stock_ledger_entry_(standard).json b/erpnext/stock/doctype_settings_map/stock_ledger_entry.json similarity index 94% rename from erpnext/stock/doctype_settings_map/stock_ledger_entry_(standard)/stock_ledger_entry_(standard).json rename to erpnext/stock/doctype_settings_map/stock_ledger_entry.json index 63557df864a..cf28dfa6ede 100644 --- a/erpnext/stock/doctype_settings_map/stock_ledger_entry_(standard)/stock_ledger_entry_(standard).json +++ b/erpnext/stock/doctype_settings_map/stock_ledger_entry.json @@ -27,6 +27,6 @@ "modified": "2026-07-10 11:41:15.124849", "modified_by": "Administrator", "module": "Stock", - "name": "Stock Ledger Entry (Standard)", + "name": "Stock Ledger Entry - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/doctype_settings_map/stock_reservation_entry_(standard)/stock_reservation_entry_(standard).json b/erpnext/stock/doctype_settings_map/stock_reservation_entry.json similarity index 94% rename from erpnext/stock/doctype_settings_map/stock_reservation_entry_(standard)/stock_reservation_entry_(standard).json rename to erpnext/stock/doctype_settings_map/stock_reservation_entry.json index e4b93cd37e9..48c68b0ebb1 100644 --- a/erpnext/stock/doctype_settings_map/stock_reservation_entry_(standard)/stock_reservation_entry_(standard).json +++ b/erpnext/stock/doctype_settings_map/stock_reservation_entry.json @@ -27,6 +27,6 @@ "modified": "2026-07-10 11:44:00.765222", "modified_by": "Administrator", "module": "Stock", - "name": "Stock Reservation Entry (Standard)", + "name": "Stock Reservation Entry - Stock", "owner": "Administrator" } diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 9b21e46559c..8dc0bd0cb3b 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -40,6 +40,28 @@ purchase_doctypes = [ NOT_APPLICABLE_TAX = "N/A" +# For each transaction, the child-row link field(s) that point to the source +# document item, mapped to that source item doctype. When "maintain same rate" is +# on, a mapped row keeps the persisted source pricing (read straight from that row), +# so an unsaved edit on the target row can never lock in a non-source rate. +maintain_same_rate_source_fields = { + "Purchase Order": {"supplier_quotation_item": "Supplier Quotation Item"}, + "Purchase Receipt": {"purchase_order_item": "Purchase Order Item"}, + "Purchase Invoice": {"po_detail": "Purchase Order Item", "pr_detail": "Purchase Receipt Item"}, + "Sales Order": {"quotation_item": "Quotation Item"}, + "Delivery Note": {"so_detail": "Sales Order Item", "si_detail": "Sales Invoice Item"}, + "Sales Invoice": {"so_detail": "Sales Order Item", "dn_detail": "Delivery Note Item"}, +} + +LOCKED_RATE_FIELDS = [ + "price_list_rate", + "rate", + "discount_percentage", + "discount_amount", + "margin_type", + "margin_rate_or_amount", +] + def _preprocess_ctx(ctx): if not ctx.price_list: @@ -121,16 +143,20 @@ def get_item_details( if ctx.doctype in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]: ctx.customer = None - out.update(get_price_list_rate(ctx, item)) + source_row = get_rate_locked_source_row(ctx, doc) + if source_row: + lock_source_rate(out, source_row) + else: + out.update(get_price_list_rate(ctx, item)) - if ( - not out.price_list_rate - and ctx.transaction_type == "selling" - and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list") - ): - fallback_args = ctx.copy() - fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list") - out.update(get_price_list_rate(fallback_args, item)) + if ( + not out.price_list_rate + and ctx.transaction_type == "selling" + and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list") + ): + fallback_args = ctx.copy() + fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list") + out.update(get_price_list_rate(fallback_args, item)) ctx.customer = current_customer @@ -145,9 +171,8 @@ def get_item_details( if ctx.get(key) is None: ctx[key] = value - data = get_pricing_rule_for_item(ctx, doc=doc, for_validate=for_validate) - - out.update(data) + if not source_row: + out.update(get_pricing_rule_for_item(ctx, doc=doc, for_validate=for_validate)) if ( frappe.get_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward") @@ -189,6 +214,61 @@ def remove_standard_fields(out: frappe._dict): return out +def get_rate_locked_source_row(ctx: ItemDetailsCtx, doc) -> frappe._dict | None: + """Return the persisted source-document row a mapped target row is locked to. + + The rate is read from the linked source row in the database (not the mutable + target row), so a re-fetch always restores the source pricing the maintain-same- + rate validator checks against, even after an unsaved edit on the target row. + """ + if isinstance(doc, str): + doc = json.loads(doc) + + source_fields = maintain_same_rate_source_fields.get(ctx.parenttype or ctx.doctype) + if not source_fields or not doc or ctx.get("is_return") or not maintain_same_rate_enabled(ctx): + return None + + row = next((d for d in doc.get("items") or [] if d.get("name") == ctx.child_docname), None) + if not row: + return None + + for link_field, source_doctype in source_fields.items(): + if source_name := row.get(link_field): + # a direct read would bypass permissions; only return source pricing to a + # caller allowed to read the source document + source = frappe.db.get_value( + source_doctype, source_name, [*LOCKED_RATE_FIELDS, "parent", "parenttype"], as_dict=True + ) + if source and frappe.has_permission(source.parenttype, doc=source.parent): + return source + return None + return None + + +def maintain_same_rate_enabled(ctx: ItemDetailsCtx) -> bool: + if (ctx.parenttype or ctx.doctype) in purchase_doctypes: + if ctx.get("is_internal_supplier"): + return False + return bool(cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate"))) + + if ctx.get("is_internal_customer"): + return False + return bool(cint(frappe.get_cached_value("Selling Settings", "None", "maintain_same_sales_rate"))) + + +def lock_source_rate(out: frappe._dict, source_row) -> None: + """Copy the source row's whole pricing block onto out so a mapped row keeps its + exact rate. Pricing rules are skipped for these rows, so nothing re-derives it and + the manual discount or margin that made rate differ from price_list_rate survives. + """ + out.price_list_rate = flt(source_row.get("price_list_rate")) or flt(source_row.get("rate")) + out.rate = flt(source_row.get("rate")) + out.discount_percentage = flt(source_row.get("discount_percentage")) + out.discount_amount = flt(source_row.get("discount_amount")) + out.margin_type = source_row.get("margin_type") + out.margin_rate_or_amount = flt(source_row.get("margin_rate_or_amount")) + + def set_valuation_rate(out: frappe._dict, ctx: frappe._dict): from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle @@ -1647,14 +1727,21 @@ def apply_price_list(ctx: ItemDetailsCtx, as_doc: bool = False, doc: Document | def apply_price_list_on_item(ctx, doc=None): item_doc = frappe.get_cached_doc("Item", ctx.item_code) - item_details = get_price_list_rate(ctx, item_doc) + + source_row = get_rate_locked_source_row(ctx, doc) + if source_row: + item_details = frappe._dict() + lock_source_rate(item_details, source_row) + else: + item_details = get_price_list_rate(ctx, item_doc) ctx.conversion_factor = flt(ctx.conversion_factor) or get_conversion_factor(ctx.item_code, ctx.uom).get( "conversion_factor", 1 ) ctx.stock_qty = flt(ctx.qty) * flt(ctx.conversion_factor) - item_details.update(get_pricing_rule_for_item(ctx, doc=doc)) + if not source_row: + item_details.update(get_pricing_rule_for_item(ctx, doc=doc)) return item_details diff --git a/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py index 7eabe37cc91..4a1622b8169 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py @@ -8,6 +8,7 @@ from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse +from erpnext.stock.doctype.warehouse.warehouse import get_warehouses_based_on_account from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( create_reposting_entries, execute, @@ -113,6 +114,23 @@ class TestStockAndAccountValueComparison(ERPNextTestSuite): ) self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based") + def test_child_account_override_excluded_from_group_account(self): + # A group warehouse carries an inventory account; a child (e.g. Goods-in-Transit) can override + # it with its own account. get_warehouses_based_on_account must return only warehouses whose + # effective account matches, excluding the overriding child. + group = create_warehouse("_Test SAVC Group WH", {"is_group": 1}, company=COMPANY) + group_account = frappe.get_value("Warehouse", group, "account") + + inheriting = create_warehouse( + "_Test SAVC Inherit WH", {"parent_warehouse": group, "account": group_account}, company=COMPANY + ) + overriding = create_warehouse("_Test SAVC Transit WH", {"parent_warehouse": group}, company=COMPANY) + + warehouses = get_warehouses_based_on_account(group_account, COMPANY) + + self.assertIn(inheriting, warehouses) + self.assertNotIn(overriding, warehouses) + def run_report(self, **extra): filters = {"company": COMPANY, "as_on_date": "2026-12-31"} filters.update(extra) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 52ea26d4383..5414274db7e 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -6,8 +6,10 @@ import copy import frappe from frappe import _ -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import IfNull, Sum from frappe.utils import cint, flt, get_datetime +from pypika import Order +from pypika.analytics import RowNumber from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -52,14 +54,15 @@ def execute(filters=None): data = [] conversion_factors = [] - if opening_row: - data.append(opening_row) + opening_rows = opening_row if isinstance(opening_row, list) else ([opening_row] if opening_row else []) + for row in opening_rows: + data.append(row) conversion_factors.append(0) actual_qty = stock_value = 0 - if opening_row: - actual_qty = opening_row.get("qty_after_transaction") - stock_value = opening_row.get("stock_value") + if opening_rows: + actual_qty = opening_rows[0].get("qty_after_transaction", 0) + stock_value = opening_rows[0].get("stock_value", 0) available_serial_nos = {} @@ -692,43 +695,120 @@ def get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value=N if not (filters.item_code and filters.warehouse and filters.from_date): return - from erpnext.stock.stock_ledger import get_previous_sle + item_codes = filters.item_code + if isinstance(item_codes, str): + item_codes = [item_codes] - project = None - if filters.get("project") and not frappe.get_all( - "Inventory Dimension", filters={"reference_document": "Project"} - ): - project = filters.get("project") + warehouses = get_matching_warehouses(filters.warehouse) + if not warehouses: + return - last_entry = get_previous_sle( - { - "item_code": filters.item_code, - "warehouse_condition": get_warehouse_condition(filters.warehouse), - "posting_date": filters.from_date, - "posting_time": "00:00:00", - "project": project, - }, - for_report=True, + sle_doctype = frappe.qb.DocType("Stock Ledger Entry") + sr_doctype = frappe.qb.DocType("Stock Reconciliation") + + opening_reco_query = ( + frappe.qb.from_(sle_doctype) + .inner_join(sr_doctype) + .on(sle_doctype.voucher_no == sr_doctype.name) + .select(sle_doctype.voucher_no) + .where(sle_doctype.docstatus < 2) + .where(sle_doctype.is_cancelled == 0) + .where(sle_doctype.item_code.isin(item_codes)) + .where(sle_doctype.warehouse.isin(warehouses)) + .where(sle_doctype.voucher_type == "Stock Reconciliation") + .where(sle_doctype.posting_date == filters.from_date) + .where(sr_doctype.purpose == "Opening Stock") ) - # check if any SLEs are actually Opening Stock Reconciliation - for sle in list(sl_entries): - if ( - sle.get("voucher_type") == "Stock Reconciliation" - and sle.posting_date == filters.from_date - and frappe.db.get_value("Stock Reconciliation", sle.voucher_no, "purpose") == "Opening Stock" - ): - last_entry = sle - sl_entries.remove(sle) + opening_reco_vouchers = set(opening_reco_query.run(pluck=True)) - row = { + if opening_reco_vouchers: + sl_entries[:] = [sle for sle in sl_entries if sle.get("voucher_no") not in opening_reco_vouchers] + + sle_cond = (sle_doctype.posting_date < filters.from_date) | ( + (sle_doctype.posting_date == filters.from_date) & (sle_doctype.posting_time == "00:00:00") + ) + if opening_reco_vouchers: + sle_cond = sle_cond | ( + (sle_doctype.posting_date == filters.from_date) + & (sle_doctype.voucher_no.isin(list(opening_reco_vouchers))) + ) + + subq = ( + frappe.qb.from_(sle_doctype) + .select( + sle_doctype.qty_after_transaction, + sle_doctype.stock_value, + RowNumber() + .over(sle_doctype.item_code, sle_doctype.warehouse) + .orderby(sle_doctype.posting_datetime, sle_doctype.creation, sle_doctype.name, order=Order.desc) + .as_("rn"), + ) + .where(sle_doctype.docstatus < 2) + .where(sle_doctype.is_cancelled == 0) + .where(sle_doctype.item_code.isin(item_codes)) + .where(sle_doctype.warehouse.isin(warehouses)) + .where(sle_cond) + ) + + for field in ["voucher_no", "project", "company"]: + if filters.get(field): + subq = subq.where(sle_doctype[field] == filters.get(field)) + + inventory_dimension_fields = get_inventory_dimension_fields() + if inventory_dimension_fields: + for fieldname in inventory_dimension_fields: + if filters.get(fieldname): + subq = subq.where(sle_doctype[fieldname].isin(filters.get(fieldname))) + + query = ( + frappe.qb.from_(subq) + .select( + IfNull(Sum(subq.qty_after_transaction), 0.0).as_("total_qty"), + IfNull(Sum(subq.stock_value), 0.0).as_("total_stock_value"), + ) + .where(subq.rn == 1) + ) + + res = query.run(as_dict=True) + + total_qty = flt(res[0].total_qty) if res else 0.0 + total_stock_value = flt(res[0].total_stock_value) if res else 0.0 + valuation_rate = flt(total_stock_value / total_qty) if total_qty else 0.0 + + return { "item_code": _("'Opening'"), - "qty_after_transaction": last_entry.get("qty_after_transaction", 0), - "valuation_rate": last_entry.get("valuation_rate", 0), - "stock_value": last_entry.get("stock_value", 0), + "qty_after_transaction": total_qty, + "valuation_rate": valuation_rate, + "stock_value": total_stock_value, } - return row + +def get_matching_warehouses(warehouses): + if not warehouses: + return [] + + if isinstance(warehouses, str): + warehouses = [warehouses] + + warehouse_details = frappe.get_all( + "Warehouse", + filters={"name": ("in", warehouses)}, + fields=["lft", "rgt"], + ) + + if not warehouse_details: + return warehouses + + wh = frappe.qb.DocType("Warehouse") + cond = None + for d in warehouse_details: + c = (wh.lft >= d.lft) & (wh.rgt <= d.rgt) + cond = c if cond is None else (cond | c) + + matching = (frappe.qb.from_(wh).select(wh.name).where(cond)).run(pluck=True) + + return matching if matching else warehouses def get_warehouse_condition(warehouses): @@ -784,7 +864,15 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value): if not filters.item_code or not filters.warehouse or not filters.from_date: return - if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1: + item_codes = filters.get("item_code") + if isinstance(item_codes, str): + item_codes = [item_codes] + + warehouses = filters.get("warehouse") + if isinstance(warehouses, str): + warehouses = [warehouses] + + if len(item_codes) > 1 or len(warehouses) > 1: return sl_doctype = frappe.qb.DocType("Stock Ledger Entry") @@ -804,17 +892,11 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value): ) ) - if filters.get("item_code"): - if isinstance(filters.item_code, list | tuple): - query = query.where(sl_doctype.item_code.isin(filters.item_code)) - else: - query = query.where(sl_doctype.item_code == filters.item_code) + if item_codes: + query = query.where(sl_doctype.item_code.isin(item_codes)) - if filters.get("warehouse"): - if isinstance(filters.warehouse, list | tuple): - query = query.where(sl_doctype.warehouse.isin(filters.warehouse)) - else: - query = query.where(sl_doctype.warehouse == filters.warehouse) + if warehouses: + query = query.where(sl_doctype.warehouse.isin(warehouses)) for key, value in inv_dimension_wise_value.items(): if isinstance(value, list | tuple): diff --git a/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py b/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py index 1f86467c54b..3ab290033f9 100644 --- a/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py +++ b/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py @@ -87,3 +87,250 @@ class TestStockLedgerReport(ERPNextTestSuite): rows = self.run_report(item_a) item_codes = {row["item_code"] for row in rows if row.get("voucher_no")} self.assertEqual(item_codes, {item_a}) + + def test_multi_item_opening_balance_with_and_without_transactions(self): + item_a = "_Test Item" + item_b = "_Test Item 2" + self.make_movements( + item_a, + [ + { + "qty": 10, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + } + ], + ) + self.make_movements( + item_b, + [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 50, "posting_date": add_days(today(), -10)}], + ) + self.make_movements( + item_a, + [{"qty": 2, "from_warehouse": WAREHOUSE, "posting_date": today()}], + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item_a, item_b], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 15) + + def test_multi_warehouse_opening_balance_aggregation(self): + item = "_Test Item" + warehouse_1 = "Stores - _TC" + warehouse_2 = "Finished Goods - _TC" + + self.make_movements( + item, + [ + { + "qty": 10, + "to_warehouse": warehouse_1, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + }, + { + "qty": 20, + "to_warehouse": warehouse_2, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + }, + ], + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=[warehouse_1, warehouse_2], + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 30) + + def test_opening_stock_reconciliation_on_from_date_non_midnight_time(self): + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + item = "_Test Item" + from_date = today() + + sr = create_stock_reconciliation( + item_code=item, + warehouse=WAREHOUSE, + qty=25, + rate=100, + posting_date=from_date, + posting_time="10:30:00", + purpose="Opening Stock", + do_not_submit=False, + ) + + filters = frappe._dict( + company="_Test Company", + from_date=from_date, + to_date=from_date, + item_code=[item], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 25) + + # Ensure the Opening Stock Reconciliation is not duplicated in detail transaction rows + reco_rows = [row for row in rows if row.get("voucher_no") == sr.name] + self.assertEqual(len(reco_rows), 0) + + def test_backdated_sle_independent_maxima_handling(self): + item = "_Test Item" + # Entry 1: Later posting date (2026-07-20), created first + self.make_movements( + item, + [ + { + "qty": 10, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + } + ], + ) + # Entry 2: Backdated posting date (2026-07-15), created LATER + self.make_movements( + item, + [ + { + "qty": 5, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -15), + } + ], + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + # Should correctly pick the latest posting date entry (15 Qty) despite backdated creation order + self.assertEqual(opening_rows[0]["qty_after_transaction"], 15) + + def test_filtered_opening_balance_does_not_pick_excluded_creation_entry(self): + item = "_Test Item" + posting_date = add_days(today(), -10) + posting_time = "09:00:00" + + included_entry = make_stock_entry( + item_code=item, + qty=10, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + make_stock_entry( + item_code=item, + qty=50, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=WAREHOUSE, + voucher_no=included_entry.name, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 10) + + def test_tied_creation_terminal_sle_is_not_summed_twice(self): + item = "_Test Item" + posting_date = add_days(today(), -10) + posting_time = "09:00:00" + + stock_entry_1 = make_stock_entry( + item_code=item, + qty=10, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + stock_entry_2 = make_stock_entry( + item_code=item, + qty=5, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + + sle_rows = frappe.get_all( + "Stock Ledger Entry", + filters={ + "voucher_type": "Stock Entry", + "voucher_no": ("in", [stock_entry_1.name, stock_entry_2.name]), + "item_code": item, + "warehouse": WAREHOUSE, + "is_cancelled": 0, + }, + fields=["name", "qty_after_transaction"], + order_by="name desc", + ) + self.assertEqual(len(sle_rows), 2) + + for sle in sle_rows: + frappe.db.set_value( + "Stock Ledger Entry", + sle.name, + "creation", + "2026-01-01 00:00:00.000000", + update_modified=False, + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], sle_rows[0].qty_after_transaction) + self.assertNotEqual( + opening_rows[0]["qty_after_transaction"], + sum(sle.qty_after_transaction for sle in sle_rows), + ) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 56812b1bfed..090f6098817 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -6,6 +6,7 @@ from frappe.model.naming import NamingSeries, parse_naming_series from frappe.query_builder.functions import Max, Sum from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, now from pypika import Order +from pypika.terms import ExistsCriterion from erpnext.stock.deprecated_serial_batch import ( DeprecatedBatchNoValuation, @@ -830,13 +831,14 @@ class BatchNoValuation(DeprecatedBatchNoValuation): ("batch-valuation", self.sle.item_code, self.sle.warehouse) ) - entries = self.get_batch_stock_before_date() self.stock_value_change = 0.0 self.batch_avg_rate = defaultdict(float) self.available_qty = defaultdict(float) self.stock_value_differece = defaultdict(float) - for ledger in entries: + self.seed_from_stock_closing_balance() + + for ledger in self.get_batch_stock_before_date(): self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate) self.available_qty[ledger.batch_no] += flt(ledger.qty) @@ -844,6 +846,52 @@ class BatchNoValuation(DeprecatedBatchNoValuation): self.calculate_avg_rate_for_non_batchwise_valuation() self.set_stock_value_difference() + def seed_from_stock_closing_balance(self): + self.stock_closing_from_datetime = None + closing_entry = self.get_closing_entry_for_seeding() + if not closing_entry: + return + + from erpnext.stock.utils import get_combine_datetime + + self.stock_closing_from_datetime = get_combine_datetime( + add_days(closing_entry.to_date, 1), "00:00:00" + ) + + for row in self.get_stock_closing_balance_entries(closing_entry.name): + self.stock_value_differece[row.batch_no] += flt(row.stock_value_difference) + self.available_qty[row.batch_no] += flt(row.actual_qty) + + def get_closing_entry_for_seeding(self): + from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import ( + get_closing_entry_for_closed_period, + ) + + if not self.batchwise_valuation_batches or not self.sle.posting_date: + return None + + company = self.sle.company or frappe.get_cached_value("Warehouse", self.sle.warehouse, "company") + closing_entry = get_closing_entry_for_closed_period(company) + if not closing_entry or getdate(self.sle.posting_date) <= getdate(closing_entry.to_date): + return None + + return closing_entry + + def get_stock_closing_balance_entries(self, closing_entry): + table = frappe.qb.DocType("Stock Closing Balance") + + return ( + frappe.qb.from_(table) + .select(table.batch_no, table.actual_qty, table.stock_value_difference) + .where( + (table.stock_closing_entry == closing_entry) + & (table.item_code == self.sle.item_code) + & (table.warehouse == self.sle.warehouse) + & table.batch_no.isin(self.batchwise_valuation_batches) + & (table.inventory_dimension_key.isnull() | (table.inventory_dimension_key == "")) + ) + ).run(as_dict=True) + def get_batch_stock_before_date(self) -> list[dict]: # Get batch wise stock value difference from Serial and Batch Bundle considering time condition if not self.batchwise_valuation_batches: @@ -851,14 +899,45 @@ class BatchNoValuation(DeprecatedBatchNoValuation): child = frappe.qb.DocType("Serial and Batch Entry") + sle_creation = self.sle.creation if self.sle.get("name") else None + if not self.sle.get("name") and self.sle.get("serial_and_batch_bundle"): + sle_creation = frappe.db.get_value( + "Stock Ledger Entry", + {"serial_and_batch_bundle": self.sle.serial_and_batch_bundle, "is_cancelled": 0}, + "creation", + ) + timestamp_condition = "" if self.sle.posting_datetime: timestamp_condition = child.posting_datetime < self.sle.posting_datetime - if self.sle.creation: - timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & ( - child.creation < self.sle.creation + sle_table = frappe.qb.DocType("Stock Ledger Entry") + if sle_creation: + # bundle creation and SLE creation are different timelines (a + # bundle can be created much before its SLE), so break the tie + # using the creation of the bundle's own SLE + tie_condition = ExistsCriterion( + frappe.qb.from_(sle_table) + .select(sle_table.name) + .where( + (sle_table.serial_and_batch_bundle == child.parent) + & (sle_table.is_cancelled == 0) + & (sle_table.creation < sle_creation) + ) ) + else: + # the current entry is not yet in the ledger and will get the + # latest creation, so the same-timestamp entries which are + # already in the ledger precede it + tie_condition = ExistsCriterion( + frappe.qb.from_(sle_table) + .select(sle_table.name) + .where( + (sle_table.serial_and_batch_bundle == child.parent) & (sle_table.is_cancelled == 0) + ) + ) + + timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & tie_condition conditions = ( (child.item_code == self.sle.item_code) @@ -878,6 +957,9 @@ class BatchNoValuation(DeprecatedBatchNoValuation): if timestamp_condition: conditions &= timestamp_condition + if self.stock_closing_from_datetime: + conditions &= child.posting_datetime >= self.stock_closing_from_datetime + # MariaDB carries a row lock on the grouped query below; on postgres the caller # (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse) # instead of row-locking the whole history (FOR UPDATE is invalid with GROUP BY there). @@ -906,6 +988,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation): self.batchwise_valuation_batches = [] self.non_batchwise_valuation_batches = [] + if batchwise_batches := self.sle.get("batchwise_valuation_batches"): + self.batchwise_valuation_batches = list(batchwise_batches) + self.non_batchwise_valuation_batches = list(set(self.batches) - set(batchwise_batches)) + return + if get_valuation_method( self.sle.item_code, self.sle.company ) == "Moving Average" and frappe.get_single_value("Stock Settings", "do_not_use_batchwise_valuation"): diff --git a/erpnext/stock/services/quality_inspection_service.py b/erpnext/stock/services/quality_inspection_service.py index 3784725a4d2..b9fab051038 100644 --- a/erpnext/stock/services/quality_inspection_service.py +++ b/erpnext/stock/services/quality_inspection_service.py @@ -50,9 +50,25 @@ QI_OUTGOING_PURPOSES = ( ) +SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble") + + +def is_inspection_exempt_secondary_row(doc, row) -> bool: + """Whether the row is a secondary item on a document that produces secondary items.""" + if not (row.get("secondary_item_type") or row.get("is_legacy_scrap_item")): + return False + + if doc.doctype == "Stock Entry": + return doc.purpose in SECONDARY_ITEM_PURPOSES + + return True + + def stock_entry_row_requires_inspection(purpose, row): """Check if this Stock Entry row need a Quality Inspection.""" - if row.get("secondary_item_type") or row.get("is_legacy_scrap_item"): + if purpose in SECONDARY_ITEM_PURPOSES and ( + row.get("secondary_item_type") or row.get("is_legacy_scrap_item") + ): return False if purpose == "Manufacture": return bool(row.is_finished_item) @@ -88,7 +104,7 @@ class QualityInspectionService: elif self.doc.doctype == "Stock Entry": qi_required = stock_entry_row_requires_inspection(self.doc.purpose, row) - if row.get("secondary_item_type") or row.get("is_legacy_scrap_item"): + if is_inspection_exempt_secondary_row(self.doc, row): continue if qi_required: # validate row only if inspection is required on item level diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 08676a9d416..5f826e8f0c6 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -101,6 +101,32 @@ def validate_standard_cost_posting_date(sl_entries): ) +def validate_stock_frozen_by_closing_entry(sl_entries): + from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import ( + get_closing_entry_for_closed_period, + ) + + company = sl_entries[0].get("company") + if not company: + company = frappe.get_cached_value("Warehouse", sl_entries[0].get("warehouse"), "company") + + closing_entry = get_closing_entry_for_closed_period(company) + if not closing_entry: + return + + for sle in sl_entries: + if sle.get("posting_date") and getdate(sle.get("posting_date")) <= getdate(closing_entry.to_date): + frappe.throw( + _( + "Stock transactions dated on or before {0} are frozen because the period is closed and the Stock Closing Entry {1} has been generated. To make changes, cancel the Period Closing Voucher first." + ).format( + frappe.bold(format_date(closing_entry.to_date)), + get_link_to_form("Stock Closing Entry", closing_entry.name), + ), + title=_("Stock Frozen"), + ) + + def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): """Create SL entries from SL entry dicts @@ -119,6 +145,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc for pair in sorted({(d.get("item_code"), d.get("warehouse")) for d in sl_entries}): sle_processing_gate(*pair) + validate_stock_frozen_by_closing_entry(sl_entries) + cancelled = sl_entries[0].get("is_cancelled") if cancelled: validate_cancellation(sl_entries) diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index f9513fb5743..e36afeabfea 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -124,3 +124,336 @@ class TestGetItemDetail(ERPNextTestSuite): dn.save() self.assertEqual(dn.items[0].batch_no, "BATCH01") self.assertEqual(dn.items[0].rate, 50) + + def test_maintain_same_rate_keeps_source_rate_on_refetch(self): + """#57436: with "maintain same rate" on, re-fetching a PR row mapped from a + PO must keep the PO rate instead of pulling a newer, higher Item Price. + + The rate is validated on save, so it can never persist changed; assert the + fetched rate directly to prove the newer Item Price is never picked up. + """ + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.doctype.item.test_item import make_item + + def set_maintain_same_rate(value): + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", value) + frappe.clear_cache(doctype="Buying Settings") + + set_maintain_same_rate(1) + + item_code = make_item(properties={"is_stock_item": 1}).name + po = create_purchase_order(item_code=item_code, qty=1, rate=100) + + # The PO may auto-insert an Item Price at 100; bump it to the newer, higher rate. + item_price = frappe.db.get_value( + "Item Price", {"item_code": item_code, "price_list": "Standard Buying"} + ) + if item_price: + frappe.db.set_value("Item Price", item_price, "price_list_rate", 120) + else: + frappe.get_doc( + { + "doctype": "Item Price", + "price_list": "Standard Buying", + "item_code": item_code, + "price_list_rate": 120, + } + ).insert() + + pr = make_purchase_receipt(po.name) + pr.insert() + + def fetch_price_list_rate(): + ctx = frappe._dict( + { + "item_code": item_code, + "doctype": "Purchase Receipt", + "name": pr.name, + "company": pr.company, + "supplier": pr.supplier, + "currency": pr.currency, + "conversion_rate": 1.0, + "price_list": "Standard Buying", + "price_list_currency": pr.currency, + "plc_conversion_rate": 1.0, + "warehouse": pr.items[0].warehouse, + "uom": pr.items[0].uom, + "stock_uom": pr.items[0].stock_uom, + "qty": pr.items[0].qty, + "child_doctype": pr.items[0].doctype, + "child_docname": pr.items[0].name, + "is_return": 0, + "is_internal_supplier": 0, + "ignore_pricing_rule": 1, + } + ) + return get_item_details(ctx, pr).get("price_list_rate") + + # Rate stays at the PO rate; the newer Item Price (120) is not fetched. + self.assertEqual(fetch_price_list_rate(), 100) + + # Control: without the setting the newer Item Price would be fetched. + set_maintain_same_rate(0) + self.assertEqual(fetch_price_list_rate(), 120) + + def test_maintain_same_rate_survives_refetch_with_discount(self): + """A mapped Purchase Receipt row that carries a source discount (rate != price + list rate) must keep its rate when the row is re-fetched, so maintain-same-rate + lets the document save. process_item_selection runs the same recompute the desk + mirrors, so it covers the "discount discarded on refresh" concern end to end. + """ + from frappe.utils import flt + + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + item, price_list = "_Test Item", "_Test Buying Price List" + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + original_action = frappe.db.get_single_value("Buying Settings", "maintain_same_rate_action") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.db.set_single_value("Buying Settings", "maintain_same_rate_action", "Stop") + frappe.clear_cache(doctype="Buying Settings") + + try: + for label, adjustment in ( + ("percentage", {"discount_percentage": 10}), + ("amount", {"discount_amount": 10}), + ): + with self.subTest(discount=label): + # a controlled discounted PO: list rate 100, effective rate 90 + frappe.flags.dont_fetch_price_list_rate = True + po = create_purchase_order(item_code=item, qty=1, do_not_save=True) + po.buying_price_list = price_list + po.items[0].price_list_rate = 100 + po.items[0].update(adjustment) + po.items[0].rate = 90 + po.insert() + po.submit() + frappe.flags.dont_fetch_price_list_rate = False + + # a newer Item Price must not leak onto the mapped row on re-fetch + item_price = frappe.db.get_value( + "Item Price", {"item_code": item, "price_list": price_list} + ) + if item_price: + frappe.db.set_value("Item Price", item_price, "price_list_rate", 250) + + pr = make_purchase_receipt(po.name) + pr.insert() + pr.process_item_selection(item_idx=pr.items[0].idx) + + self.assertEqual(flt(pr.items[0].rate), 90) + pr.save() # must not raise the maintain-same-rate check + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.db.set_single_value("Buying Settings", "maintain_same_rate_action", original_action) + frappe.clear_cache(doctype="Buying Settings") + frappe.flags.dont_fetch_price_list_rate = False + + def test_apply_price_list_keeps_source_rate_when_maintain_same_rate(self): + """#57436: the bulk apply_price_list path (price list / party / conversion rate + change) must also keep the source rate on mapped rows, not just re-fetch of a + single row. Here a PR row carries its PO rate (175) while the current price list + rate is 100; the bulk apply must keep 175. + """ + from frappe.utils import flt, nowdate + + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.get_item_details import apply_price_list + + item_code = "_Test Item" + price_list = "_Test Buying Price List" + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + + try: + po = create_purchase_order(item_code=item_code, rate=175, qty=1) + + row_name = "pr-row-1" + pr_doc = { + "doctype": "Purchase Receipt", + "items": [ + { + "name": row_name, + "item_code": item_code, + "purchase_order_item": po.items[0].name, + "price_list_rate": 175, + "rate": 175, + } + ], + } + ctx = frappe._dict( + doctype="Purchase Receipt", + supplier=po.supplier, + company=po.company, + currency=po.currency, + conversion_rate=1.0, + price_list=price_list, + plc_conversion_rate=1.0, + transaction_date=nowdate(), + items=[ + frappe._dict( + doctype="Purchase Receipt Item", + parenttype="Purchase Receipt", + item_code=item_code, + child_docname=row_name, + qty=1, + uom=po.items[0].uom, + stock_uom=po.items[0].stock_uom, + conversion_factor=1.0, + ) + ], + ) + + result = apply_price_list(ctx, doc=pr_doc) + self.assertEqual(flt(result["children"][0].get("price_list_rate")), 175) + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") + + def test_maintain_same_rate_keeps_source_discount_on_refetch(self): + """A mapped source row with a discount has rate != price_list_rate. Re-fetch must + return the source's rate and discount, not just the pre-discount price, or the + recomputed rate diverges from the reference and fails maintain-same-rate on save. + """ + from frappe.utils import flt + + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + item_code = "_Test Item" + price_list = "_Test Buying Price List" + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + + try: + # source PO carries the discount: list rate 100, 10% off, effective rate 90 + frappe.flags.dont_fetch_price_list_rate = True + po = create_purchase_order(item_code=item_code, qty=1, do_not_save=True) + po.buying_price_list = price_list + po.items[0].price_list_rate = 100 + po.items[0].discount_percentage = 10 + po.items[0].rate = 90 + po.insert() + po.submit() + frappe.flags.dont_fetch_price_list_rate = False + + row_name = "pr-row-1" + pr_doc = { + "doctype": "Purchase Receipt", + "items": [ + {"name": row_name, "item_code": item_code, "purchase_order_item": po.items[0].name} + ], + } + ctx = frappe._dict( + item_code=item_code, + doctype="Purchase Receipt", + company=po.company, + supplier=po.supplier, + currency=po.currency, + conversion_rate=1.0, + price_list=price_list, + price_list_currency=po.currency, + plc_conversion_rate=1.0, + warehouse="_Test Warehouse - _TC", + uom=po.items[0].uom, + stock_uom=po.items[0].stock_uom, + qty=1, + child_docname=row_name, + is_return=0, + is_internal_supplier=0, + ignore_pricing_rule=1, + ) + + out = get_item_details(ctx, pr_doc) + self.assertEqual(flt(out.get("price_list_rate")), 100) + self.assertEqual(flt(out.get("rate")), 90) + self.assertEqual(flt(out.get("discount_percentage")), 10) + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") + frappe.flags.dont_fetch_price_list_rate = False + + def test_refetch_restores_source_rate_after_target_edit(self): + """Editing a mapped row's rate then re-fetching must restore the persisted source + rate (read from the linked row), not lock in the edit, so the document still saves. + """ + from frappe.utils import flt + + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + item = "_Test Item" + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + original_action = frappe.db.get_single_value("Buying Settings", "maintain_same_rate_action") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.db.set_single_value("Buying Settings", "maintain_same_rate_action", "Stop") + frappe.clear_cache(doctype="Buying Settings") + + try: + po = create_purchase_order(item_code=item, qty=1, rate=90) + pr = make_purchase_receipt(po.name) + pr.insert() + + # user edits the mapped row to a non-source rate + pr.items[0].price_list_rate = 200 + pr.items[0].rate = 200 + + # a re-fetch must restore the persisted source (PO) rate, not keep the edit + pr.process_item_selection(item_idx=pr.items[0].idx) + self.assertEqual(flt(pr.items[0].rate), 90) + pr.save() # must not raise the maintain-same-rate check + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.db.set_single_value("Buying Settings", "maintain_same_rate_action", original_action) + frappe.clear_cache(doctype="Buying Settings") + + def test_rate_lock_source_lookup_checks_permission(self): + """The lock reads source pricing via a direct DB read, so it must not disclose a + source document's pricing to a caller who cannot read that document. + """ + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.get_item_details import get_rate_locked_source_row + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + + role, email = "_Test Role Without PO Access", "_test_rate_lock_probe@example.com" + try: + po = create_purchase_order(item_code="_Test Item", qty=1, rate=90) + pr_doc = { + "doctype": "Purchase Receipt", + "items": [{"name": "r1", "item_code": "_Test Item", "purchase_order_item": po.items[0].name}], + } + ctx = frappe._dict(doctype="Purchase Receipt", child_docname="r1") + + # an authorized caller receives the source row + self.assertIsNotNone(get_rate_locked_source_row(ctx.copy(), dict(pr_doc))) + + if not frappe.db.exists("Role", role): + frappe.get_doc({"doctype": "Role", "role_name": role, "desk_access": 1}).insert( + ignore_permissions=True + ) + if not frappe.db.exists("User", email): + frappe.get_doc( + { + "doctype": "User", + "email": email, + "first_name": "Probe", + "send_welcome_email": 0, + "roles": [{"role": role}], + } + ).insert(ignore_permissions=True) + + frappe.set_user(email) + # a caller who cannot read the Purchase Order gets nothing + self.assertIsNone(get_rate_locked_source_row(ctx.copy(), dict(pr_doc))) + finally: + frappe.set_user("Administrator") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") diff --git a/erpnext/stock/workspace/stock/stock.json b/erpnext/stock/workspace/stock/stock.json index e98890a399e..07519124ef9 100644 --- a/erpnext/stock/workspace/stock/stock.json +++ b/erpnext/stock/workspace/stock/stock.json @@ -799,7 +799,7 @@ "type": "Link" } ], - "modified": "2026-07-05 12:08:07.187999", + "modified": "2026-07-30 11:42:33.379243", "modified_by": "Administrator", "module": "Stock", "module_onboarding": "Stock Onboarding", @@ -1022,7 +1022,7 @@ { "child": 1, "collapsible": 1, - "default_workspace": 1, + "default_workspace": 0, "icon": "", "indent": 0, "keep_closed": 0, diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json index b55f02f9f52..e51a4ea6bf3 100644 --- a/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json +++ b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json @@ -107,6 +107,7 @@ "fieldname": "conversion_factor", "fieldtype": "Float", "label": "Conversion Factor", + "precision": "9", "read_only": 1 }, { @@ -128,7 +129,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-27 13:10:45.904619", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting BOM", diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json index 11da413fc14..9b20def35c2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json @@ -87,6 +87,7 @@ "fieldtype": "Float", "hidden": 1, "label": "Conversion Factor", + "precision": "9", "read_only": 1 }, { @@ -186,7 +187,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-18 18:04:04.204651", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Inward Order Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json index 44ec2185ce6..19df4007581 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json @@ -174,6 +174,7 @@ "fieldtype": "Float", "hidden": 1, "label": "Conversion Factor", + "precision": "9", "read_only": 1 }, { @@ -425,7 +426,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-02-27 23:03:36.436504", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Order Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.json b/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.json index acd6aae6220..8a1c41ed499 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.json @@ -63,6 +63,7 @@ "fieldtype": "Float", "hidden": 1, "label": "Conversion Factor", + "precision": "9", "read_only": 1 }, { @@ -176,7 +177,7 @@ "hide_toolbar": 1, "istable": 1, "links": [], - "modified": "2025-10-30 16:00:43.379828", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Order Supplied Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json index 4a0f1176c69..6ba81c05c15 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -205,6 +205,7 @@ "fieldtype": "Float", "hidden": 1, "label": "Conversion Factor", + "precision": "9", "read_only": 1 }, { @@ -657,7 +658,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-07-18 10:00:00.000000", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json index 8d26da40863..ec10fd07146 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json @@ -134,6 +134,7 @@ "fieldtype": "Float", "hidden": 1, "label": "Conversion Factor", + "precision": "9", "read_only": 1 }, { @@ -275,7 +276,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-07-18 10:00:00.000000", + "modified": "2026-08-07 17:31:31.732720", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt Supplied Item", diff --git a/erpnext/subcontracting/doctype_settings_map/subcontracting_inward_order_(standard)/subcontracting_inward_order_(standard).json b/erpnext/subcontracting/doctype_settings_map/subcontracting_inward_order.json similarity index 90% rename from erpnext/subcontracting/doctype_settings_map/subcontracting_inward_order_(standard)/subcontracting_inward_order_(standard).json rename to erpnext/subcontracting/doctype_settings_map/subcontracting_inward_order.json index 10308ef06a9..3ee97242394 100644 --- a/erpnext/subcontracting/doctype_settings_map/subcontracting_inward_order_(standard)/subcontracting_inward_order_(standard).json +++ b/erpnext/subcontracting/doctype_settings_map/subcontracting_inward_order.json @@ -19,6 +19,6 @@ "modified": "2026-07-03 13:03:18.132340", "modified_by": "Administrator", "module": "Subcontracting", - "name": "Subcontracting Inward Order (Standard)", + "name": "Subcontracting Inward Order - Subcontracting", "owner": "Administrator" } diff --git a/erpnext/subcontracting/doctype_settings_map/subcontracting_order_(standard)/subcontracting_order_(standard).json b/erpnext/subcontracting/doctype_settings_map/subcontracting_order.json similarity index 93% rename from erpnext/subcontracting/doctype_settings_map/subcontracting_order_(standard)/subcontracting_order_(standard).json rename to erpnext/subcontracting/doctype_settings_map/subcontracting_order.json index bed24ffb9bd..aea0a8c2072 100644 --- a/erpnext/subcontracting/doctype_settings_map/subcontracting_order_(standard)/subcontracting_order_(standard).json +++ b/erpnext/subcontracting/doctype_settings_map/subcontracting_order.json @@ -27,6 +27,6 @@ "modified": "2026-07-21 17:10:04.037735", "modified_by": "Administrator", "module": "Subcontracting", - "name": "Subcontracting Order (Standard)", + "name": "Subcontracting Order - Subcontracting", "owner": "Administrator" } diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index dd071c0b717..07e9c40ebd7 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -547,7 +547,9 @@ class TransactionBase(StatusUpdater): from erpnext.stock.get_item_details import apply_price_list args = { - "items": [x.as_dict() for x in self.items], + # pass child_docname so the maintain-same-rate lock in apply_price_list can + # match each row, consistent with the desk (JS) callers + "items": [{**x.as_dict(), "child_docname": x.name} for x in self.items], "customer": self.customer or self.party_name, "quotation_to": self.quotation_to, "customer_group": self.customer_group, @@ -616,13 +618,13 @@ def validate_uom_is_integer(doc, uom_field, qty_fields, child_dt=None): for f in qty_fields: qty = d.get(f) if qty: - precision = d.precision(f) - if abs(cint(qty) - flt(qty, precision)) > 0.0000001: + qty = flt(qty, d.precision(f)) + if qty != cint(qty): frappe.throw( _( "Row {1}: Quantity ({0}) cannot be a fraction. To allow this, disable '{2}' in UOM {3}." ).format( - flt(qty, precision), + qty, d.idx, frappe.bold(_("Must be Whole Number")), frappe.bold(d.get(uom_field)),