Compare commits

...

76 Commits

Author SHA1 Message Date
Mihir Kandoi
c1e0865771 fix(controllers): drop the Check-field guard from dimension search (#58783) 2026-09-06 07:27:26 +00:00
Afsal Syed
71f1edeb1c fix(stock): show actual outgoing rate in ledger preview (#58700) 2026-09-05 18:43:11 +05:30
Shllokkk
135921ca79 Merge pull request #58769 from Shllokkk/fix-profit-and-loss-dashboard-chart
fix: restore hover tooltip on Profit and Loss dashboard chart
2026-09-05 14:35:01 +05:30
Shllokkk
9e9c4b8102 fix: restore hover tooltip on Profit and Loss dashboard chart
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 14:22:37 +05:30
Raffael Meyer
5beed5f4f0 fix: resolve code lists by URI and version (#58770) 2026-09-04 20:21:50 +02:00
Nikhil Kothari
04b84ef069 fix(banking): find transfers on the same day (#58766) 2026-09-04 13:16:19 +00:00
Hussain Nagaria
154c6fb943 fix(bank reconciliation): match Payment Entries on the bank-side amount (#57740)
* fix(bank reconciliation): match Payment Entries on the bank-side amount

get_pe_matching_query() ranked and filtered on pe.paid_amount while the
match card displayed pe.base_paid_amount_after_tax, so the amount used for
the exact match never matched the amount shown.

Both now use the amount that actually hits the bank account, in that
account's currency: received_amount_after_tax when the bank account is
paid_to (deposit) and paid_amount_after_tax when it is paid_from
(withdrawal). This is the same convention as the Bank Reconciliation
Statement report and matches the bank GL entry that reconciliation
allocates against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(bank reconciliation): cover bank-side amount matching

Two cases the previous behaviour got wrong or could regress on:

- A deposit from an internal transfer where the paid and received sides
  differ by a charge. The match must show, and compare against, the
  amount that reached this bank account.
- A withdrawal, which still matches on the paid side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:20:09 +05:30
Vishnu Priya Baskaran
c3319d74cf fix: add reconciliation after submit logic for bank transactions (#57330)
Co-authored-by: Poovetha <poovethapalanivelu@gmail.com>
2026-09-04 17:05:39 +05:30
Mihir Kandoi
b2233a44d1 revert(manufacturing): remove material coverage changes (#58714) 2026-09-04 14:32:28 +05:30
Pandiyan P
5895ed0ee9 fix: prevent duplicate Batch messages and Project links (#58705) 2026-09-04 14:31:23 +05:30
Abdeali Chharchhodawala
1b7da82669 fix: minor improvements to financial report template validation (#58724)
* fix: address review comments on financial report template validation

* refactor: minor fixes
2026-09-04 14:00:57 +05:30
Pandiyan P
0b1f1d6851 fix: check material request price list permission (#58740) 2026-09-04 13:02:59 +05:30
Pandiyan P
00f04fc084 fix(batch): show Expired status only after expiry date has passed (#58736)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-04 12:21:01 +05:30
Krishna Pramod Shirsath
f368a5b64e fix(timesheet): handle empty allowed projects (#58745) 2026-09-04 11:34:29 +05:30
Soham Kulkarni
f8951034be Merge pull request #58729 from sokumon/sidebar-changes
fix: more sidebar improvements
2026-09-03 15:16:36 +05:30
sokumon
56caf3c770 fix: more sidebar improvements 2026-09-03 14:46:30 +05:30
Sumit Jain
3b9535ed91 Merge pull request #58726 from frappe/fix-account-tree
feat(accounts): disabled marker and cost center parity in tree views
2026-09-03 14:05:18 +05:30
Sumit Jain
f8c53be376 fix(accounts): drop unsupported badge theme on account number 2026-09-03 13:53:27 +05:30
Sumit Jain
a1b402020e feat(accounts): enhance account and cost center tree views with additional flags and labels 2026-09-03 13:38:20 +05:30
Diptanil Saha
e3a976f115 fix(setup): strict permissions for transaction deletion record (#58687) 2026-09-03 06:28:23 +00:00
dependabot[bot]
68666c2ed0 chore(deps): bump browserslist from 4.28.4 to 4.28.8 in /banking (#58721)
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.4 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.4...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 05:33:45 +00:00
Diptanil Saha
717af27559 ci: authenticate github clones in install.sh (#58718) 2026-09-03 10:50:32 +05:30
Sagar Vora
4944df8733 Merge pull request #58697 from sagarvora/frt-fixes
fix!: improve validation in financial report template
2026-09-02 17:08:04 +05:30
Sagar Vora
19aa3b20e0 fix!: improve validation in financial report template 2026-09-02 16:46:02 +05:30
rohitwaghchaure
09f5e76b77 fix: rearrange fields in BOM Operation (#58683) 2026-09-02 16:44:16 +05:30
rohitwaghchaure
646c7d042d fix: check write permission in whitelisted document methods (#58689)
* fix: check write permission in whitelisted document methods

* fix: keep production plan status roll-up permission agnostic
2026-09-02 15:40:10 +05:30
Shllokkk
3e08a085b9 Merge pull request #58684 from Shllokkk/lead-add-to-prospect-read-permission-check
fix(crm): check read permission on lead in add_lead_to_prospect
2026-09-02 14:54:01 +05:30
Krishna Pramod Shirsath
2918e98a2b fix(stock): allow creating stock closing balances (#58590) 2026-09-02 08:24:46 +00:00
Shllokkk
02fcdc0337 fix(crm): check read permission on lead in add_lead_to_prospect 2026-09-02 13:50:38 +05:30
Krishna Pramod Shirsath
a2071a6fdd fix: filter cancelled BOMs in BOM Stock Analysis (#58647) 2026-09-02 13:33:15 +05:30
Vishnu Priya Baskaran
dbe153a15e fix: include payment deductions in sales/purchase register ledger bal… (#58437) 2026-09-02 12:30:08 +05:30
Vishnu Priya Baskaran
f16f249a38 fix(pos): use company-currency change amount when netting pos gl entries (#58599) 2026-09-02 12:22:24 +05:30
Vishnu Priya Baskaran
9cb736a271 fix(stock): prorate landed cost charge into transaction currency (#58575)
* fix(stock): prorate landed cost charge into transaction currency

* test(stock): assert landed cost charge transaction-currency amount
2026-09-02 12:10:22 +05:30
Shllokkk
c93815b4ae fix: filter fully ordered items when creating RFQ from Material Request (#58534) 2026-09-02 06:15:04 +00:00
Pandiyan P
2b54a581f8 fix(stock): preserve exchange gain loss journals in lcv (#58505) 2026-09-02 11:44:38 +05:30
Mihir Kandoi
199cae9496 fix(stock): subtract stock qty of same-document rows from batch availability (#58669)
* fix(stock): subtract stock qty of same-document rows from batch availability

filter_batches subtracted a row's transaction-UOM qty from batch quantities that
are in the stock UOM, so a row in an alternate UOM freed less of the batch than
it consumes and the auto-pick could assign a batch that cannot cover the new
row.

* test(stock): cover batch availability with alternate UOM rows
2026-09-02 11:42:37 +05:30
Mihir Kandoi
9261c9b47f fix(stock): assign batch_no only when the first batch covers the full qty (#58668)
* fix(stock): assign batch_no only when the first batch covers the full qty

The auto-pick loop reduced the requested qty per batch and left the last
visited batch on the row, so a qty spanning batches got a batch that could
not fulfil it and failed at submit with a misleading negative-stock error.

Assign the first batch in pick order only when it alone covers the qty.
Otherwise leave batch_no empty so the auto-created Serial and Batch Bundle
splits the qty across batches at submit.

Batches are queried without qty so filter_batches subtracts rows already in
the document from the uncapped batch quantities. Querying on a copy also
stops get_auto_batch_nos from clearing warehouse on the kwargs later used to
pick serial nos.

Fixes #58640

* test(stock): cover batch auto-pick when qty spans batches

* fix(stock): pick serial nos across batches when no batch covers the qty

With batch_no left empty for a qty that spans batches, the serial pick for a
serialised and batched item filtered on [None] and returned nothing, leaving
the row with neither identity. Skip the batch filter when there is no batch so
the serial nos are picked in the configured order across batches; the bundle
built at submit derives each serial's batch.

* test(stock): cover serial pick across batches for batched serial items
2026-09-02 11:42:37 +05:30
Mihir Kandoi
0230879501 ci: allow shallow fetch when fast-forwarding the cached bench (#58666) 2026-09-02 11:20:21 +05:30
kaulith
56a391c522 fix(crm): validate contact email before saving an email campaign (#58667) 2026-09-02 05:34:22 +00:00
Pandiyan P
e74ab38eeb fix(manufacturing): use item warehouses in production plan work orders (#58663) 2026-09-02 04:34:05 +00:00
Pandiyan P
a9b857bdfe fix(stock): ignore cancelled batch entries in valuation (#58659) 2026-09-01 15:49:51 +00:00
Diptanil Saha
a20fc4f931 fix: add missing perm checks on various whitelisted methods (#58652) 2026-09-01 19:12:21 +05:30
Shllokkk
22e3b87621 Merge pull request #58607 from Shllokkk/trial-balance-party-exclude-zero-rounding
fix: round party balances to currency precision in Trial Balance for Party
2026-09-01 15:18:22 +05:30
Mihir Kandoi
e2f24eea15 feat(buying): track supplier quotation order status (#58572) 2026-09-01 08:18:29 +00:00
Pandiyan P
db52e465d0 fix: handle BOM price list currency update (#58638) 2026-09-01 13:31:37 +05:30
Raffael Meyer
5691a7b3c7 fix: explain missing fields when loading party details or taxes (#58627) 2026-09-01 01:31:06 +02:00
Raffael Meyer
0d78261bdf fix: translate label in party validation (#58624) 2026-09-01 00:47:53 +02:00
Diptanil Saha
120e62f208 fix(crm): add missing permission validation on get_contract_template (#58621) 2026-09-01 00:58:58 +05:30
Diptanil Saha
21a3b1c3dc fix: validate POS Settings invoice and search fields on the server (#58611) 2026-08-31 23:43:35 +05:30
Raffael Meyer
d8432d92c8 fix: do not map the same row twice in "Get Items From" (#58617) 2026-08-31 17:15:22 +02:00
Sumit Jain
31761699ae fix: enhance tree view functionality for accounts and cost centers (#58520) 2026-08-31 19:56:24 +05:30
Pandiyan P
7ecfa6b356 fix(stock): correct reservation and pick list quantities (#58613) 2026-08-31 19:38:31 +05:30
Mihir Kandoi
24209ae699 fix(manufacturing): handle duplicate root BOM items (#58614)
* fix(manufacturing): handle duplicate root BOM items

* test(manufacturing): remove duplicate root item test
2026-08-31 14:04:26 +00:00
Pandiyan P
26d000e15f fix(stock): validate serial batch bundle company (#58608) 2026-08-31 12:08:23 +00:00
Mihir Kandoi
dbae23765e fix: keep closed rows out of Update Items (#58609) 2026-08-31 12:03:00 +00:00
Mihir Kandoi
c755e24731 fix: compare updated item quantities in stock UOM (#58603) 2026-08-31 17:05:30 +05:30
Mihir Kandoi
d5789c2e8b feat: close individual transaction items (#57596) 2026-08-31 16:57:48 +05:30
Pandiyan P
0f14f8050f fix: correct bom sorting and stock translations (#58605) 2026-08-31 11:26:42 +00:00
Shllokkk
b1c7657dfa fix: round party balances to currency precision in Trial Balance for Party 2026-08-31 16:43:31 +05:30
rohitwaghchaure
b36895a4c3 feat: production plan visualizer page and summary report fix (#58541)
* fix: production plan summary report tree structure and quantities

* feat: production plan visualizer page

* feat: single screen production plan visualizer with material readiness

* fix: show live stock and received status for production plan materials

* fix: remove duplicate border under production plan visualizer header

* fix: drop page head border on production plan visualizer

* fix: add horizontal margin to production plan visualizer

* fix: apply record level permissions and resolve shared material owners

* fix: list shared raw materials under every finished good that needs them

* feat: open linked documents in a side panel from the visualizer

* fix: never fall back to stored qty when warehouse stock is not readable

* fix: include directly consuming finished goods in material ownership

* fix: show each finished good's own share of shared material demand

* fix: match production plan quantities and labels in the visualizer

* fix: resolve nested sub assembly owners when parent link is missing

* fix: keep every matching owner when resolving sub assemblies by item code

* feat: flat work order list in place of the items to manufacture tree

* fix: flatten items to manufacture rows without changing the table design

* fix: align table numbers, units and progress cells

* fix: scope nested owner resolution to the same sales order

* fix: keep quantity columns numeric and move uom to the item line

* fix: recover all finished goods for consolidated sub assembly rows

* fix: scope raw material owners to the same sales order
2026-08-31 16:24:12 +05:30
Mihir Kandoi
509501c299 feat(manufacturing): add multi-currency support to Blanket Orders (#58472) 2026-08-31 09:45:34 +00:00
Smit Vora
20ea09b4a0 Merge pull request #58483 from ljain112/opening-taxes
fix(taxes): skip tax addition for invoice created from opening invoice tool
2026-08-31 15:05:12 +05:30
Mihir Kandoi
8ca2905a33 feat: valuation method for BOM secondary items (#58431) 2026-08-31 14:51:45 +05:30
Pandiyan P
9087f2cdba fix: widen item name in stock projected qty (#58598) 2026-08-31 08:32:17 +00:00
Mihir Kandoi
6cca7d670b fix: restore isolated loyalty and subcontracting tests (#58587) 2026-08-31 13:40:14 +05:30
Diptanil Saha
4355f8e60e fix(pos): add permission checks on get_invoices (#58591) 2026-08-31 13:23:59 +05:30
Diptanil Saha
0e4b384af1 fix(accounts): added permission checks on get_available_payment_schedules (#58588) 2026-08-31 07:36:01 +00:00
Mihir Kandoi
83dea1a24e test: prevent update_doctypes from exporting files (#58589) 2026-08-31 07:31:41 +00:00
Aarol D'Souza
6d445d94ee Merge pull request #58586 from AarDG10/patch-erpnext
fix(sms_settings): add patch to pre-fill roles into SMS Settings Role Table
2026-08-31 12:27:32 +05:30
Aarol D'Souza
05e5e50a6f Merge branch 'develop' into patch-erpnext 2026-08-31 11:51:59 +05:30
AarDG10
3501beb2bd fix(sms_settings): add patch to pre-fill roles into SMS Settings Roles Table 2026-08-31 11:38:47 +05:30
Nishka Gosalia
7af1af35cd Merge pull request #58325 from Shllokkk/project-duplicate-read-permission-check
fix(projects): check read permission on source in create_duplicate_project
2026-08-31 11:14:40 +05:30
Mihir Kandoi
86852d954e test: narrow shared fixture hardening (#58581) 2026-08-31 05:26:28 +00:00
ljain112
e08a166281 fix(taxes): skip tax addition for invoice created from opening invoice tool 2026-08-27 15:32:39 +05:30
Shllokkk
4a43e5203f Merge branch 'develop' into project-duplicate-read-permission-check 2026-08-24 13:14:19 +05:30
Shllokkk
8c8b282a2e fix(projects): check read permission on source in create_duplicate_project 2026-08-24 13:03:09 +05:30
229 changed files with 9670 additions and 2184 deletions

View File

@@ -60,10 +60,13 @@ Flag a changed query that uses any of these:
check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)`
emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column
(`column is of type smallint but expression is of type boolean`). Pass `1`/`0`.
- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc.
frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator
does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**,
not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there.
- **A direct `.like()`/`.ilike()` on a pypika field (or raw `LIKE`) on a NON-text column** — `idx`,
`docstatus`, a date, etc. frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text`
operator (`operator does not exist: bigint ~~* unknown`). Cast the column to text first —
**`Cast_(col, "varchar")`**, not `Cast(col, "char")` (see below). MariaDB coerces the int
implicitly, so the cast is a no-op there. A `["like", …]` filter passed to `get_all`/`get_list`/
`qb.get_query`/`reportview` needs no cast: the framework casts non-text fields itself
(frappe/frappe#42449).
- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so
`CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string.
Use `VARCHAR` / `Cast_(x, "varchar")`.
@@ -192,8 +195,9 @@ pick a bound for a stated reason, and cover the varying-group case with a test.
These are auto-handled by the framework and are **not** breaks:
- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a
case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` —
is a hard break, `bigint ILIKE`; see §1.)*
case-sensitivity bug. A `["like", …]` filter on a **non-text** field is also cast to text by
the framework. *(Exception: a direct `.like()` on a **non-text** pypika field — `idx`,
`docstatus` — is a hard break, `bigint ILIKE`; see §1.)*
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on
PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a

View File

@@ -4,6 +4,36 @@ set -e
cd ~ || exit
# Authenticate git against github.com with the job token: anonymous git-over-HTTPS from the
# runners gets throttled to a 401, which kills whichever clone is in flight — the frappe fetch
# below, or payments under `bench get-app`. See the PR description.
#
# A credential helper rather than a url.insteadOf rewrite, because `git clone` PERSISTS a
# rewritten URL into the new repo's .git/config: an insteadOf would leave the token sitting in
# apps/payments/.git/config on the runner. A helper is consulted only when github.com actually
# challenges, and leaves the stored remote URL untouched. Passing it through GIT_CONFIG_* keeps
# the token out of ~/.gitconfig too, and child processes inherit it (bench shells out to git).
ci_github_token=${CI_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}
if [ -n "$ci_github_token" ]; then
export CI_GITHUB_TOKEN="$ci_github_token"
export GIT_CONFIG_COUNT=3
# Reset first: git runs EVERY configured helper and calls `store` on them after a successful
# auth, so a `credential.helper=store` inherited from the image's gitconfig would write the
# token to ~/.git-credentials. An empty value clears the list before ours is added.
export GIT_CONFIG_KEY_0="credential.helper"
export GIT_CONFIG_VALUE_0=""
export GIT_CONFIG_KEY_1="credential.https://github.com.username"
export GIT_CONFIG_VALUE_1="x-access-token"
export GIT_CONFIG_KEY_2="credential.https://github.com.helper"
# Single-quoted: $CI_GITHUB_TOKEN is expanded by the shell git runs the helper in, so the
# token is read from the environment at call time and never stored anywhere. Answering only
# `get` makes the helper inert for git's `store`/`erase` calls.
export GIT_CONFIG_VALUE_2='!f() { test "$1" = get && echo "password=$CI_GITHUB_TOKEN"; }; f'
fi
# Whatever happens, never sit on a credential prompt: fail fast and legibly instead.
export GIT_TERMINAL_PROMPT=0
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-}
@@ -188,7 +218,7 @@ restore_warm_bench() {
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
# straight from it (bench init names the remote 'upstream', not 'origin', and points
# it at this local clone — so a plain `git fetch origin` does not work).
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
git fetch --no-tags --update-shallow "$HOME/frappe" HEAD || exit 1
git checkout --force FETCH_HEAD || exit 1
); then
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"

View File

@@ -121,6 +121,8 @@ jobs:
env:
DB: mariadb
TYPE: server
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
- name: Run Patch Tests
run: |

View File

@@ -129,6 +129,8 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
- name: Run Tests
run: |

View File

@@ -102,6 +102,8 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
DB_HOST: 127.0.0.1
DB_USER_HOST: '%'
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb

View File

@@ -103,6 +103,8 @@ jobs:
DB: postgres
TYPE: server
FRAPPE_BRANCH: develop
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
BENCH_CACHE_DIR: /home/runner/bench-cache
- name: Warm up test data

File diff suppressed because one or more lines are too long

View File

@@ -1489,10 +1489,10 @@ balanced-match@^4.0.2:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
baseline-browser-mapping@^2.10.38:
version "2.10.40"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz#f372c8eb36ff4ad0b5e7ae467014abef124554ba"
integrity sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==
baseline-browser-mapping@^2.11.12:
version "2.11.20"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303"
integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==
brace-expansion@^5.0.5:
version "5.0.7"
@@ -1509,15 +1509,15 @@ brace-expansion@^5.0.8:
balanced-match "^4.0.2"
browserslist@^4.24.0:
version "4.28.4"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.4.tgz#dd8b8167a32845ff5f8cd6ce13f5abba16cd04c9"
integrity sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==
version "4.28.8"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0"
integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==
dependencies:
baseline-browser-mapping "^2.10.38"
caniuse-lite "^1.0.30001799"
electron-to-chromium "^1.5.376"
node-releases "^2.0.48"
update-browserslist-db "^1.2.3"
baseline-browser-mapping "^2.11.12"
caniuse-lite "^1.0.30001809"
electron-to-chromium "^1.5.402"
node-releases "^2.0.53"
update-browserslist-db "^1.3.0"
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
@@ -1527,10 +1527,10 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
es-errors "^1.3.0"
function-bind "^1.1.2"
caniuse-lite@^1.0.30001799:
version "1.0.30001800"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz#b896c773e1c39400809415162bb5320371291b36"
integrity sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==
caniuse-lite@^1.0.30001809:
version "1.0.30001810"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2"
integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==
ccount@^2.0.0:
version "2.0.1"
@@ -1697,10 +1697,10 @@ dunder-proto@^1.0.1:
es-errors "^1.3.0"
gopd "^1.2.0"
electron-to-chromium@^1.5.376:
version "1.5.383"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz#5bd22306497d454103b289b0fef97260c56d0855"
integrity sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==
electron-to-chromium@^1.5.402:
version "1.5.420"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz#fc66d26a722d6f227e2092acdf38dd55b198cb44"
integrity sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==
engine.io-client@~6.5.1:
version "6.5.4"
@@ -3012,10 +3012,10 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
node-releases@^2.0.48:
version "2.0.50"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.50.tgz#597197a852071ce42fc2550e58e223242bcba969"
integrity sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==
node-releases@^2.0.53:
version "2.0.54"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe"
integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==
object-assign@^4.1.1:
version "4.1.1"
@@ -3589,10 +3589,10 @@ unist-util-visit@^5.0.0:
unist-util-is "^6.0.0"
unist-util-visit-parents "^6.0.0"
update-browserslist-db@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==
update-browserslist-db@^1.3.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz#9d99fbff56c50bb11ba5fd35cece5916da595836"
integrity sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==
dependencies:
escalade "^3.2.0"
picocolors "^1.1.1"

View File

@@ -9,7 +9,7 @@
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2025-12-19 12:37:31.673782",
"modified": "2026-09-04 12:37:31.673782",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss",
@@ -17,7 +17,6 @@
"owner": "Administrator",
"report_name": "Profit and Loss Statement",
"roles": [],
"show_values_over_chart": 1,
"timeseries": 0,
"type": "Line",
"use_report_chart": 1,

View File

@@ -122,6 +122,7 @@
"description": "Setting Account Type helps in selecting this Account in transactions.",
"fieldname": "account_type",
"fieldtype": "Select",
"in_preview": 1,
"in_standard_filter": 1,
"label": "Account Type",
"oldfieldname": "account_type",
@@ -203,7 +204,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2026-08-21 23:11:37.851001",
"modified": "2026-09-03 12:59:42.190900",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",

View File

@@ -52,6 +52,42 @@ frappe.treeview_settings["Account"] = {
],
root_label: "Accounts",
get_tree_nodes: "erpnext.accounts.utils.get_children",
get_label: function (node) {
// clean display name — the account number renders as a badge (see
// onrender) instead of being glued into the name
return frappe.utils.escape_html(node.data.account_name || node.title || node.label);
},
onrender: function (node) {
if (node.is_root || !node.data) return;
const flags = [];
if (node.data.account_number) {
flags.push(frappe.ui.badge({ label: node.data.account_number }));
}
const company = frappe.treeview_settings["Account"].treeview?.page?.fields_dict?.company?.get_value();
const company_currency = company && erpnext.get_currency(company);
if (
node.data.account_currency &&
company_currency &&
node.data.account_currency !== company_currency
) {
flags.push(frappe.ui.badge({ label: node.data.account_currency, theme: "blue" }));
}
if (node.data.freeze_account === "Yes") {
flags.push(
frappe.ui.badge({
label: __("Frozen"),
icon: "lock",
title: __("Frozen - entries restricted"),
theme: "orange",
})
);
}
erpnext.utils.render_tree_node_flags(node, flags);
},
on_node_render: function (node, deep) {
const render_balances = () => {
for (let account of cur_tree.account_balance_data) {
@@ -232,7 +268,7 @@ frappe.treeview_settings["Account"] = {
frappe.treeview_settings["Account"].treeview["tree"] = treeview.tree;
if (treeview.can_create) {
treeview.page.set_primary_action(
__("New"),
{ label: __("Add Account"), short_label: __("Add") },
function () {
let root_company = treeview.page.fields_dict.root_company.get_value();
if (root_company) {
@@ -243,13 +279,14 @@ frappe.treeview_settings["Account"] = {
treeview.new_node();
}
},
"add"
"plus"
);
}
},
toolbar: [
{
label: __("Add Child"),
icon: "plus",
condition: function (node) {
return (
frappe.boot.user.can_create.indexOf("Account") !== -1 &&
@@ -272,6 +309,7 @@ frappe.treeview_settings["Account"] = {
return !node.root && frappe.boot.user.can_read.indexOf("GL Entry") !== -1;
},
label: __("View Ledger"),
icon: "book-open",
click: function (node, btn) {
frappe.route_options = {
from_date: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
@@ -286,6 +324,106 @@ frappe.treeview_settings["Account"] = {
},
btnClass: "hidden-xs",
},
{
// same label and mechanism as the Account form's Actions button:
// NOT frappe's generic rename (Allow Rename stays off) — this is
// ERPNext's controlled update that rebuilds the derived
// "number - name - abbr" document name
label: __("Update Account Name / Number"),
icon: "text-cursor-input",
condition: function (node) {
return !node.is_root && frappe.model.can_write("Account");
},
click: function (node) {
const dialog = new frappe.ui.Dialog({
title: __("Update Account Number / Name"),
fields: [
{
fieldtype: "Data",
fieldname: "account_name",
label: __("Account Name"),
reqd: 1,
default: node.data.account_name,
},
{
fieldtype: "Data",
fieldname: "account_number",
label: __("Account Number"),
default: node.data.account_number,
},
],
primary_action_label: __("Update"),
primary_action(values) {
dialog.hide();
frappe.dom.freeze(__("Updating {0}", [node.label]));
frappe.call({
method: "erpnext.accounts.doctype.account.account.update_account_number",
args: {
name: node.label,
account_name: values.account_name,
account_number: values.account_number,
},
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees["Account"];
node.parent_node && treeview.tree.load_children(node.parent_node);
},
always: function () {
frappe.dom.unfreeze();
},
});
},
});
dialog.show();
},
},
{
label: __("Convert to Group"),
icon: "folder-tree",
condition: function (node) {
return !node.is_root && !node.expandable && frappe.model.can_write("Account");
},
click: function (node) {
erpnext.accounts.convert_tree_node("Account", node, "convert_ledger_to_group");
},
},
{
label: __("Convert to Non-Group"),
icon: "file-text",
condition: function (node) {
// only on groups the user has opened and found empty — a
// group with children can't convert, so don't offer it
return (
!node.is_root &&
node.expandable &&
node.loaded &&
!node.$ul.children().length &&
frappe.model.can_write("Account")
);
},
click: function (node) {
erpnext.accounts.convert_tree_node("Account", node, "convert_group_to_ledger");
},
},
],
extend_toolbar: true,
};
frappe.provide("erpnext.accounts");
// shared by the Account and Cost Center tree views (defined in both files,
// whichever loads first wins): run the doctype's whitelisted convert method,
// then re-render the branch so the node's group/leaf state updates
erpnext.accounts.convert_tree_node =
erpnext.accounts.convert_tree_node ||
function (doctype, node, method) {
frappe.call({
method: "run_doc_method",
args: { dt: doctype, dn: node.label, method: method },
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees[doctype];
node.parent_node && treeview.tree.load_children(node.parent_node);
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
},
});
};

View File

@@ -912,7 +912,7 @@ def search_for_transfer_transaction(transaction_id: str | int):
days = frappe.db.get_single_value("Accounts Settings", "transfer_match_days")
if not days:
if days is None:
days = 3
min_date = frappe.utils.add_days(date, -days)
@@ -1336,9 +1336,11 @@ def get_pe_matching_query(
ref_condition = pe.reference_no == transaction.reference_number
ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
amount_equality = pe.paid_amount == transaction.unallocated_amount
amount_field = pe.received_amount_after_tax if account_from_to == "paid_to" else pe.paid_amount_after_tax
amount_equality = amount_field == transaction.unallocated_amount
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
amount_condition = amount_equality if exact_match else pe.paid_amount > 0.0
amount_condition = amount_equality if exact_match else amount_field > 0.0
party_condition = (
(pe.party_type == transaction.party_type) & (pe.party == transaction.party) & pe.party.isnotnull()
@@ -1355,7 +1357,7 @@ def get_pe_matching_query(
(ref_rank + amount_rank + party_rank + 1).as_("rank"),
ConstantColumn("Payment Entry").as_("doctype"),
pe.name,
pe.base_paid_amount_after_tax.as_("paid_amount"),
amount_field.as_("paid_amount"),
pe.reference_no,
pe.reference_date,
pe.party,

View File

@@ -10,6 +10,7 @@ from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool
auto_reconcile_vouchers,
get_auto_reconcile_message,
get_bank_transactions,
get_linked_payments,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -99,13 +100,14 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
def make_bank_transaction(self, date, deposit=100):
def make_bank_transaction(self, date, deposit=100, withdrawal=0):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": date,
"deposit": deposit,
"withdrawal": withdrawal,
"bank_account": self.bank_account,
"currency": "INR",
}
@@ -114,11 +116,73 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
.submit()
)
def get_matching_payment_entries(self, bank_transaction, exact_match=False):
document_types = ["payment_entry", "exact_match"] if exact_match else ["payment_entry"]
vouchers = get_linked_payments(
bank_transaction,
document_types,
from_date=add_days(today(), -1),
to_date=today(),
)
return [v for v in vouchers if v.get("doctype") == "Payment Entry"]
def test_get_bank_transactions_excludes_dates_after_to_date(self):
self.make_bank_transaction(date=today())
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_deposit_matches_amount_received_in_bank_account(self):
# money leaves another bank account and lands here minus a charge, so the two sides differ
payment = frappe.get_doc(
{
"doctype": "Payment Entry",
"payment_type": "Internal Transfer",
"company": self.company,
"posting_date": today(),
"paid_from": "_Test Bank - _TC",
"paid_to": self.bank,
"paid_amount": 3537.64,
"received_amount": 3460.52,
"reference_no": "TRF-001",
"reference_date": today(),
}
)
payment.set_missing_values()
payment.set_exchange_rate()
payment.set_amounts()
payment.deductions[-1].account = "_Test Exchange Gain/Loss - _TC"
payment.deductions[-1].cost_center = "_Test Cost Center - _TC"
payment = payment.save().submit()
transaction = self.make_bank_transaction(date=today(), deposit=3460.52)
# the received side is what reached this bank account, so that is what is shown
matches = self.get_matching_payment_entries(transaction.name)
self.assertEqual([m["name"] for m in matches], [payment.name])
self.assertEqual(matches[0]["paid_amount"], 3460.52)
# and what the exact match compares against
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
def test_withdrawal_matches_amount_paid_from_bank_account(self):
payment = create_payment_entry(
company=self.company,
payment_type="Pay",
party_type="Supplier",
party="_Test Supplier",
paid_from=self.bank,
paid_to="Creditors - _TC",
paid_amount=1250,
)
payment = payment.save().submit()
transaction = self.make_bank_transaction(date=today(), deposit=0, withdrawal=1250)
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
self.assertEqual(exact_matches[0]["paid_amount"], 1250)
def test_auto_reconcile_message_for_no_matches(self):
message, indicator = get_auto_reconcile_message([], [])
self.assertEqual(indicator, "blue")

View File

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

View File

@@ -16,6 +16,8 @@ frappe.ui.form.on("Chart of Accounts Importer", {
() => generate_tree_preview(frm),
() => create_import_button(frm),
() => frm.set_df_property("chart_preview", "hidden", 0),
// the preview is the point of this page — open it right away
() => frm.fields_dict.chart_preview.collapse(false),
]);
}
@@ -128,7 +130,6 @@ var create_import_button = function (frm) {
freeze_message: __("Creating Accounts..."),
callback: function (r) {
if (!r.exc) {
clearInterval(frm.page["interval"]);
frm.page.set_indicator(__("Import Successful"), "blue");
create_reset_button(frm);
}
@@ -142,42 +143,95 @@ var create_reset_button = function (frm) {
frm.page
.set_primary_action(__("Reset"), function () {
frm.page.clear_primary_action();
delete frm.page["show_import_button"];
frm.reload_doc();
})
.addClass("btn btn-primary");
};
var validate_coa = function (frm) {
if (frm.doc.import_file) {
let parent = __("All Accounts");
return frappe.call({
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
args: {
file_name: frm.doc.import_file,
parent: parent,
doctype: "Chart of Accounts Importer",
file_type: frm.doc.file_type,
for_validate: 1,
},
callback: function (r) {
if (r.message["show_import_button"]) {
frm.page["show_import_button"] = Boolean(r.message["show_import_button"]);
}
},
});
}
};
var generate_tree_preview = function (frm) {
let parent = __("All Accounts");
$(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
const wrapper = $(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
// search + expand/collapse-all lean on frappe.ui.Tree helpers added with
// row mode; when running against an older frappe that predates them, skip
// this toolbar so the preview still renders (just without the extras)
const has_row_helpers =
typeof frappe.ui.Tree.prototype.get_expansion_state === "function" &&
typeof frappe.ui.Tree.prototype.filter_nodes === "function";
let tree;
let deep_loaded = false;
let search_text = "";
let update_buttons = () => {};
if (has_row_helpers) {
// same toolbar anatomy as the tree view: search on the left,
// expand/collapse-all on the right (three-state: fully collapsed ->
// Expand All, fully expanded -> Collapse All, partially expanded -> both)
const $toolbar = $('<div class="flex items-center gap-2 mb-2"></div>').appendTo(wrapper);
const search_control = frappe.ui.form.make_control({
df: { fieldtype: "Data", fieldname: "preview_search", placeholder: __("Search") },
parent: $toolbar,
only_input: true,
});
search_control.refresh();
$(search_control.wrapper).addClass("m-0").css("width", "220px");
search_control.$input.addClass("input-xs");
search_control.$input.on(
"input",
frappe.utils.debounce(() => {
search_text = search_control.$input.val();
const run = () => {
// a newer keystroke superseded this one while the deep load ran
if (search_text !== search_control.$input.val()) return;
tree.filter_nodes(search_text);
};
if (!search_text || deep_loaded) {
run();
return;
}
tree.load_children(tree.root_node, true).then(() => {
deep_loaded = true;
run();
});
}, 300)
);
const $actions = $('<div class="ms-auto flex items-center gap-1"></div>').appendTo($toolbar);
update_buttons = () => {
const state = tree.get_expansion_state();
$expand_all.prop("disabled", !(state === "collapsed" || state === "partial"));
$collapse_all.prop("disabled", !(state === "expanded" || state === "partial"));
};
// tooltip on a wrapper: a disabled es-button has pointer-events:none,
// so hover falls through to the wrapper and the tooltip still shows
const make_action = (icon, label, onclick) => {
const $btn = $(
frappe.ui.button({ icon, disabled: true, onclick, attrs: { "aria-label": label } })
);
const $wrapper = $('<span class="inline-flex"></span>').append($btn).appendTo($actions);
frappe.ui.tooltip($wrapper, { text: label });
return $btn;
};
var $expand_all = make_action("chevrons-up-down", __("Expand All"), () => {
tree.load_children(tree.root_node, true).then(() => {
deep_loaded = true;
});
});
var $collapse_all = make_action("chevrons-down-up", __("Collapse All"), () => {
tree.load_children(tree.root_node, false);
});
}
// generate tree structure based on the csv data
return new frappe.ui.Tree({
parent: $(frm.fields_dict["chart_tree"].wrapper),
tree = new frappe.ui.Tree({
parent: wrapper,
label: parent,
expandable: true,
// read-only preview: row-mode visuals without actions or hover cards
// (ignored by an older frappe, which renders the legacy tree)
row_style: true,
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
args: {
file_name: frm.doc.import_file,
@@ -185,8 +239,9 @@ var generate_tree_preview = function (frm) {
doctype: "Chart of Accounts Importer",
file_type: frm.doc.file_type,
},
onclick: function (node) {
parent = node.value;
},
on_node_render: () => update_buttons(),
// expanded flips right after this callback — check on the next tick
on_click: () => setTimeout(update_buttons, 0),
});
return tree;
};

View File

@@ -12,6 +12,19 @@ frappe.treeview_settings["Cost Center"] = {
],
root_label: "Cost Centers",
get_tree_nodes: "erpnext.accounts.utils.get_children",
get_label: function (node) {
// clean display name — the number renders as a badge (see onrender)
return frappe.utils.escape_html(node.data.cost_center_name || node.title || node.label);
},
onrender: function (node) {
if (node.is_root || !node.data) return;
const flags = [];
if (node.data.cost_center_number) {
flags.push(frappe.ui.badge({ label: node.data.cost_center_number }));
}
erpnext.utils.render_tree_node_flags(node, flags);
},
add_tree_node: "erpnext.accounts.utils.add_cc",
menu_items: [
{
@@ -42,6 +55,37 @@ frappe.treeview_settings["Cost Center"] = {
},
],
ignore_fields: ["parent_cost_center"],
toolbar: [
{
label: __("Convert to Group"),
icon: "folder-tree",
condition: function (node) {
return !node.is_root && !node.expandable && frappe.model.can_write("Cost Center");
},
click: function (node) {
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_ledger_to_group");
},
},
{
label: __("Convert to Non-Group"),
icon: "file-text",
condition: function (node) {
// only on groups the user has opened and found empty — a
// group with children can't convert, so don't offer it
return (
!node.is_root &&
node.expandable &&
node.loaded &&
!node.$ul.children().length &&
frappe.model.can_write("Cost Center")
);
},
click: function (node) {
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_group_to_ledger");
},
},
],
extend_toolbar: true,
onload: function (treeview) {
function get_company() {
return treeview.page.fields_dict.company.get_value();
@@ -82,3 +126,22 @@ frappe.treeview_settings["Cost Center"] = {
);
},
};
frappe.provide("erpnext.accounts");
// shared by the Account and Cost Center tree views (defined in both files,
// whichever loads first wins): run the doctype's whitelisted convert method,
// then re-render the branch so the node's group/leaf state updates
erpnext.accounts.convert_tree_node =
erpnext.accounts.convert_tree_node ||
function (doctype, node, method) {
frappe.call({
method: "run_doc_method",
args: { dt: doctype, dn: node.label, method: method },
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees[doctype];
node.parent_node && treeview.tree.load_children(node.parent_node);
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
},
});
};

View File

@@ -32,6 +32,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_validat
AccountFilterValidator,
CalculationFormulaValidator,
DependencyValidator,
get_valid_api_method,
)
from erpnext.accounts.report.financial_statements import (
get_columns,
@@ -1182,10 +1183,12 @@ class RowProcessor:
def _process_api_row(self, row) -> RowData:
api_path = row.calculation_formula
# TODO
method = get_valid_api_method(api_path)
try:
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
if row.reverse_sign:
values = [-1 * v for v in values]

View File

@@ -236,6 +236,8 @@ async function refresh_tree_view(dialog, account_rows) {
parent: wrapper,
label: company,
root_value: company,
// read-only preview: row-mode visuals without actions
row_style: true,
method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_children_accounts",
args: { doctype: "Account", company: company, filtered_accounts: filtered_accounts, missed: missed },
toolbar: [],
@@ -370,7 +372,7 @@ function update_formula_description(frm, data_source) {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Custom API Setup</h5>
<p ${text_style}>Path to your custom method that returns financial data.</p>
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
<h6 ${subtitle_style}>Format:</h6>
<ul ${list_style}>
@@ -380,7 +382,8 @@ function update_formula_description(frm, data_source) {
<h6 ${subtitle_style}>Method Signature:</h6>
<div ${code_style}>
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br>&nbsp; # filters: dict — report filters (company, period, etc.) <br>&nbsp; # periods: list[dict] — period definitions <br>&nbsp; # row: dict — the current report row <br><br>&nbsp; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
<!-- &#10; is used for line breaks since frappe.render replaces newlines with spaces -->
<pre ${pre_style} class="language-python">@frappe.whitelist(methods=["GET"])&#10;def get_custom_data(filters, periods, row):&#10; # filters: dict — report filters (company, period, etc.)&#10; # periods: list[dict] — period definitions&#10; # row: dict — the current report row&#10;&#10; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
</div>
<h6 ${subtitle_style}>Return Format:</h6>

View File

@@ -8,10 +8,25 @@ from dataclasses import dataclass, field
from typing import Any
import frappe
from frappe import _
from frappe import _, is_whitelisted
from frappe.database.operator_map import OPERATOR_MAP
def get_valid_api_method(api_path: str):
"""Resolve `api_path`, ensuring it is whitelisted and permits GET (i.e. read-only)."""
method = frappe.get_attr(api_path)
is_whitelisted(method)
if "GET" not in frappe.allowed_http_methods_for_whitelisted_func.get(method, ()):
frappe.throw(
_("Method {0} must permit GET requests").format(frappe.bold(api_path)),
frappe.PermissionError,
title=_("Method Not Allowed"),
)
return method
def get_formula_field_label(data_source: str) -> str:
# Must mirror the `labels` map in financial_report_template.js (update_formula_label),
labels = {
@@ -175,8 +190,10 @@ class TemplateStructureValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required for {1}").format(
get_formula_field_label(row.data_source), row.data_source
message=_("{0} is required when {1} is {2}").format(
get_formula_field_label(row.data_source),
row.meta.get_translated_label("data_source"),
_(row.data_source),
),
row_idx=row.idx,
)
@@ -204,7 +221,14 @@ class DependencyValidator(Validator):
for row in self.template.rows:
if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula:
deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes))
# skip self-reference, `CalculationFormulaValidator` already reports it
deps = [
code
for code in extract_reference_codes_from_formula(
row.calculation_formula, list(available_codes)
)
if code != row.reference_code
]
if deps:
graph[row.reference_code] = deps
@@ -266,7 +290,9 @@ class DependencyValidator(Validator):
row_idx = self._get_row_idx(ref_code)
result.add_error(
ValidationIssue(
message=_("Line References undefined in Formula: {0}").format(", ".join(undefined)),
message=_("Line references undefined in {0}: {1}").format(
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
),
row_idx=row_idx,
)
)
@@ -293,17 +319,6 @@ class CalculationFormulaValidator(Validator):
if row.data_source != "Calculated Amount":
return result
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required for Calculated Amount").format(
get_formula_field_label(row.data_source)
),
row_idx=row.idx,
)
)
return result
formula = self._preprocess_formula(row.calculation_formula)
row.calculation_formula = formula
@@ -328,16 +343,6 @@ class CalculationFormulaValidator(Validator):
)
)
# Check undefined references
undefined = set(refs) - set(available_codes)
if undefined:
result.add_error(
ValidationIssue(
message=_("Formula references undefined codes: {0}").format(", ".join(undefined)),
row_idx=row.idx,
)
)
# Try to evaluate with dummy values
eval_error = self._test_formula_evaluation(formula, available_codes)
if eval_error:
@@ -400,17 +405,6 @@ class AccountFilterValidator(Validator):
if row.data_source != "Account Data":
return result
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required for Account Data").format(
get_formula_field_label(row.data_source)
),
row_idx=row.idx,
)
)
return result
try:
filter_config = json.loads(row.calculation_formula)
error = self._validate_filter_structure(
@@ -422,7 +416,9 @@ class AccountFilterValidator(Validator):
if error:
result.add_error(
ValidationIssue(
message=_("{0}: {1}").format(get_formula_field_label(row.data_source), error),
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source), error
),
row_idx=row.idx,
)
)
@@ -430,8 +426,9 @@ class AccountFilterValidator(Validator):
except json.JSONDecodeError as e:
result.add_error(
ValidationIssue(
message=_("{0}: Invalid JSON format: {1}").format(
get_formula_field_label(row.data_source), str(e)
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source),
_("Invalid JSON format: {0}").format(str(e)),
),
row_idx=row.idx,
)
@@ -531,29 +528,24 @@ class FormulaValidator(Validator):
)
return result
# Method exists?
try:
module_path, method_name = api_path.rsplit(".", 1)
module = frappe.get_module(module_path)
if not hasattr(module, method_name):
result.add_error(
ValidationIssue(
message=_(
"{0}: Method '{1}' not found in module '{2}' (might be environment-specific)"
).format(get_formula_field_label(row.data_source), method_name, module_path),
row_idx=row.idx,
)
)
get_valid_api_method(api_path)
except Exception as e:
result.add_error(
ValidationIssue(
message=_("Could not validate {0}: {1}").format(
get_formula_field_label(row.data_source), str(e)
),
row_idx=row.idx,
if isinstance(e, frappe.PermissionError | frappe.ValidationError):
# frappe.throw inside get_valid_api_method logs a message that would pop up in UI
frappe.clear_last_message()
if isinstance(e, frappe.PermissionError):
message = _("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source),
_("Method '{0}' must be whitelisted and permit GET requests").format(api_path),
)
)
else:
message = _("Could not validate {0}: {1}").format(
get_formula_field_label(row.data_source), str(e)
)
result.add_error(ValidationIssue(message=message, row_idx=row.idx))
return result

View File

@@ -2,7 +2,12 @@
# For license information, please see license.txt
import frappe
from frappe.tests.utils import whitelist_for_tests
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
FormulaValidator,
get_valid_api_method,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -72,3 +77,90 @@ class FinancialReportTemplateTestCase(ERPNextTestSuite):
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
)
return template
def not_whitelisted_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["POST"])
def whitelisted_post_only_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["GET"])
def whitelisted_get_method(**kwargs):
return [42.0]
class TestCustomAPIValidation(FinancialReportTemplateTestCase):
"""Custom API rows must point to whitelisted methods that permit GET"""
TEST_MODULE = "erpnext.accounts.doctype.financial_report_template.test_financial_report_template"
NOT_WHITELISTED = f"{TEST_MODULE}.not_whitelisted_method"
WHITELISTED_POST_ONLY = f"{TEST_MODULE}.whitelisted_post_only_method"
WHITELISTED_GET = f"{TEST_MODULE}.whitelisted_get_method"
def create_api_template(self, api_path):
template = self.create_test_template_with_rows(
[
{
"reference_code": "API001",
"display_name": "API Row",
"data_source": "Custom API",
"calculation_formula": api_path,
}
]
)
template.report_type = "Profit and Loss Statement"
return template
def test_get_valid_api_method(self):
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.NOT_WHITELISTED)
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.WHITELISTED_POST_ONLY)
self.assertEqual(get_valid_api_method(self.WHITELISTED_GET), frappe.get_attr(self.WHITELISTED_GET))
def test_save_rejects_invalid_api_methods(self):
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
self.assertRaises(frappe.ValidationError, template.insert)
def test_save_allows_get_whitelisted_method(self):
template = self.create_api_template(self.WHITELISTED_GET)
template.insert()
template.delete()
def test_engine_rejects_invalid_api_methods(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
self.assertRaises(frappe.PermissionError, processor._process_api_row, template.rows[0])
def test_engine_calls_valid_api_method(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
template = self.create_api_template(self.WHITELISTED_GET)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
row_data = processor._process_api_row(template.rows[0])
self.assertEqual(row_data.values, [42.0])
def test_validation_keeps_message_log_clean(self):
validator = FormulaValidator(frappe._dict(rows=[]))
message_count = len(frappe.local.message_log)
# last path raises AppNotInstalledError, which also logs a message via frappe.throw
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY, "missing_app.api.method"):
row = frappe._dict(data_source="Custom API", calculation_formula=api_path, idx=1)
result = validator.validate(row)
self.assertFalse(result.is_valid)
self.assertEqual(len(frappe.local.message_log), message_count)

View File

@@ -56,7 +56,9 @@ class LedgerMerge(Document):
@frappe.whitelist()
def form_start_merge(docname: str):
return frappe.get_doc("Ledger Merge", docname).start_merge()
lm_doc = frappe.get_doc("Ledger Merge", docname)
lm_doc.check_permission("write")
return lm_doc.start_merge()
def start_merge(docname):

View File

@@ -1,6 +1,6 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
from unittest.mock import patch
import frappe
from frappe.query_builder.functions import Sum
@@ -196,7 +196,7 @@ class TestLoyaltyProgram(ERPNextTestSuite):
for d in company_wise_info:
self.assertTrue(d.get("loyalty_points"))
@unittest.mock.patch("erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_details")
@patch("erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_details")
def test_tier_selection(self, mock_get_loyalty_details):
# Create a new loyalty program with multiple tiers
loyalty_program = frappe.get_doc(

View File

@@ -297,6 +297,9 @@ def start_import(invoices):
invoice_number = d.invoice_number
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
# the outstanding amount is entered inclusive of tax, so taxes must not
# be added on top of it
doc.flags.dont_auto_add_taxes = True
doc.insert(set_name=invoice_number)
doc.submit()
if not frappe.in_test:

View File

@@ -4,9 +4,11 @@
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.tests.utils import ERPNextTestSuite
@@ -126,6 +128,55 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
)
def test_opening_invoice_creation_without_taxes(self):
company = "_Test Opening Invoice Company"
template = frappe.get_doc(
{
"doctype": "Sales Taxes and Charges Template",
"company": company,
"title": "_Test Opening Invoice Tax",
"taxes": [
{
"charge_type": "On Net Total",
"account_head": create_account(
account_name="_Test Opening Tax Account",
parent_account="Duties and Taxes - _TOIC",
account_type="Tax",
company=company,
),
"description": "Test taxes",
"rate": 9,
}
],
}
).insert()
# makes the template the default for the party, as it would be on a live site
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
tool = self.make_invoices(company=company, return_doc=True)
invoices = tool.make_invoices()
self.assertEqual(len(invoices), 2)
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
for invoice in invoices:
si = frappe.get_doc("Sales Invoice", invoice)
self.assertFalse(si.taxes)
self.assertEqual(si.grand_total, 200)
self.assertEqual(si.outstanding_amount, 200)
# the same invoice created outside the tool keeps the default taxes,
# since adding them there is the user's decision
si = frappe.get_doc(tool.get_invoices()[0])
si.flags.ignore_mandatory = True
si.insert()
self.assertTrue(si.taxes)
self.assertEqual(si.grand_total, 218)
def test_opening_entry_project_linking(self):
doc = self.make_invoices(
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True

View File

@@ -92,6 +92,7 @@ def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, pag
@frappe.whitelist()
def make_payment_records(name: str, supplier: str, mode_of_payment: str | None = None):
doc = frappe.get_doc("Payment Order", name)
doc.check_permission()
make_journal_entry(doc, supplier, mode_of_payment)

View File

@@ -1359,6 +1359,7 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
@frappe.whitelist()
def get_available_payment_schedules(reference_doctype: str, reference_name: str):
ref_doc = frappe.get_doc(reference_doctype, reference_name)
ref_doc.check_permission()
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
return []

View File

@@ -263,12 +263,15 @@ def get_cashiers(doctype: str, txt: str, searchfield: str, start: int, page_len:
@frappe.whitelist()
def get_invoices(start: str | datetime, end: str | datetime, pos_profile: str, user: str):
invoice_doctype = frappe.db.get_single_value("POS Settings", "invoice_type")
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
frappe.has_permission("Sales Invoice", throw=True)
sales_inv_query = build_invoice_query("Sales Invoice", user, pos_profile, start, end)
query = sales_inv_query
if invoice_doctype == "POS Invoice":
frappe.has_permission("POS Invoice", throw=True)
pos_inv_query = build_invoice_query("POS Invoice", user, pos_profile, start, end)
query = query + pos_inv_query

View File

@@ -12,8 +12,9 @@
{
"fieldname": "fieldname",
"fieldtype": "Data",
"hidden": 1,
"label": "Fieldname"
"in_list_view": 1,
"label": "Fieldname",
"read_only": 1
},
{
"fieldname": "field",
@@ -26,7 +27,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-07-29 18:08:40.323579",
"modified": "2026-08-31 20:41:12.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Search Fields",

View File

@@ -1,40 +1,9 @@
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
let search_fields_datatypes = [
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
];
let do_not_include_fields = [
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
];
function is_valid_invoice_field(df) {
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
}
frappe.ui.form.on("POS Settings", {
onload: function (frm) {
@@ -42,74 +11,97 @@ frappe.ui.form.on("POS Settings", {
frm.trigger("add_search_options");
},
invoice_type: function (frm) {
frm.trigger("get_invoice_fields");
},
get_invoice_fields: function (frm) {
frappe.model.with_doctype("POS Invoice", () => {
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
if (
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
["Button"].includes(d.fieldtype)
) {
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
} else {
return null;
}
});
const invoice_type = frm.doc.invoice_type;
if (!invoice_type) return;
frappe.model.with_doctype(invoice_type, () => {
// the invoice type can change again while the meta loads
if (frm.doc.invoice_type !== invoice_type) return;
const fields = frappe.get_doc("DocType", invoice_type).fields.filter(is_valid_invoice_field);
frm.fields_dict.invoice_fields.grid.update_docfield_property(
"fieldname",
"options",
[""].concat(fields)
[""].concat(
fields.map((df) => {
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
})
)
);
frm.trigger("validate_invoice_fields");
});
},
validate_invoice_fields: function (frm) {
const valid_fieldnames = frappe
.get_doc("DocType", frm.doc.invoice_type)
.fields.filter(is_valid_invoice_field)
.map((df) => df.fieldname);
const invalid_fields = (frm.doc.invoice_fields || [])
.filter((row) => row.fieldname && !valid_fieldnames.includes(row.fieldname))
.map((row) => `#${row.idx} ${row.fieldname}`);
if (!invalid_fields.length) return;
frappe.msgprint({
title: __("Invalid POS Fields"),
indicator: "orange",
message: __("The following rows are not valid fields of {0} and have to be removed: {1}", [
frm.doc.invoice_type.bold(),
invalid_fields.join(", "),
]),
});
},
add_search_options: function (frm) {
frappe.model.with_doctype("Item", () => {
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (
search_fields_datatypes.includes(d.fieldtype) &&
!do_not_include_fields.includes(d.fieldname)
) {
return [d.label];
} else {
return null;
}
});
frappe.call({
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
callback: ({ message }) => {
const fields = message || [];
fields.unshift("");
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
frm.searchable_item_fields = Object.fromEntries(
fields.map((df) => [df.option, df.fieldname])
);
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
"field",
"options",
[""].concat(fields.map((df) => df.option))
);
},
});
},
});
frappe.ui.form.on("POS Search Fields", {
field: function (frm, doctype, name) {
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
return d;
} else {
return null;
}
})[0];
const doc = frappe.get_doc(doctype, name);
doc.fieldname = df.fieldname;
frm.refresh_field("fields");
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
frm.refresh_field("pos_search_fields");
},
});
frappe.ui.form.on("POS Field", {
fieldname: function (frm, doctype, name) {
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
return doc.fieldname == d.fieldname ? d : null;
})[0];
const doc = frappe.get_doc(doctype, name);
const invoice_meta = frappe.get_doc("DocType", frm.doc.invoice_type);
const df = invoice_meta?.fields.find((d) => d.fieldname === doc.fieldname);
if (!df) return;
doc.label = df.label;
doc.reqd = df.reqd;
doc.options = df.options;
doc.fieldtype = df.fieldtype;
doc.default_value = df.default;
frm.refresh_field("fields");
frm.refresh_field("invoice_fields");
},
});

View File

@@ -5,8 +5,46 @@ from collections import Counter
import frappe
from frappe import _
from frappe.model import no_value_fields
from frappe.model.document import Document
SEARCH_FIELD_TYPES = (
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
)
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
DO_NOT_INCLUDE_FIELDS = (
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
)
class POSSettings(Document):
# begin: auto-generated types
@@ -32,17 +70,10 @@ class POSSettings(Document):
if old_doc.invoice_type != self.invoice_type:
self.validate_invoice_type()
self.validate_duplicate_invoice_fields()
self.validate_invoice_fields()
def validate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
self.validate_duplicate_pos_search_fields()
self.validate_pos_search_fields()
def validate_invoice_type(self):
pos_opening_entries_count = frappe.db.count(
@@ -55,3 +86,94 @@ class POSSettings(Document):
),
title=_("Invoice Document Type Selection Error"),
)
def validate_duplicate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
def validate_invoice_fields(self):
if not self.invoice_type:
return
meta = frappe.get_meta(self.invoice_type)
for field in self.invoice_fields:
df = meta.get_field(field.fieldname)
if not df or not is_valid_invoice_field(df):
frappe.throw(
title=_("Invalid POS Field"),
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_(self.invoice_type))
),
)
# read only in the form, so keep them in sync with the invoice
field.label = df.label
field.fieldtype = df.fieldtype
field.options = df.options
def validate_duplicate_pos_search_fields(self):
fieldnames = [field.fieldname for field in self.pos_search_fields]
duplicate_fieldnames = {key for key, value in Counter(fieldnames).items() if value > 1}
for fieldname in duplicate_fieldnames:
frappe.throw(
title=_("Duplicate POS Search Fields"),
msg=_("'{0}' has been already added.").format(fieldname),
)
def validate_pos_search_fields(self):
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
for field in self.pos_search_fields:
df = searchable_fields.get(field.fieldname)
if not df:
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
field.idx, frappe.bold(field.fieldname or "")
),
)
if field.field != get_search_field_option(df):
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' does not match {2}.").format(
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
),
)
def is_valid_invoice_field(df):
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
def get_searchable_item_fields():
return [
df
for df in frappe.get_meta("Item").fields
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
]
def get_search_field_option(df):
# the fieldname keeps the option unique, two Item fields can share a label
return f"{df.label} ({df.fieldname})"
@frappe.whitelist()
def get_pos_search_field_options():
frappe.has_permission("POS Settings", throw=True)
return [
{"option": get_search_field_option(df), "fieldname": df.fieldname}
for df in get_searchable_item_fields()
]

View File

@@ -1,8 +1,135 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
from erpnext.tests.utils import ERPNextTestSuite
class TestPOSSettings(ERPNextTestSuite):
pass
def setUp(self):
self.settings = frappe.get_single("POS Settings")
self.settings.invoice_fields = []
self.settings.pos_search_fields = []
def assertInvalid(self, message):
with self.assertRaises(frappe.ValidationError) as context:
self.settings.save()
self.assertIn(message, str(context.exception))
def test_invoice_field_is_validated_against_invoice_type(self):
# consolidated_invoice exists on POS Invoice only
self.settings.invoice_type = "POS Invoice"
self.settings.append("invoice_fields", {"fieldname": "consolidated_invoice"})
self.settings.save()
self.settings.invoice_type = "Sales Invoice"
self.assertInvalid("is not a valid field of")
def test_field_common_to_both_invoice_types_is_allowed(self):
for invoice_type in ("POS Invoice", "Sales Invoice"):
self.settings.invoice_type = invoice_type
self.settings.invoice_fields = []
self.settings.append("invoice_fields", {"fieldname": "po_no"})
self.settings.save()
def test_unknown_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
self.assertInvalid("is not a valid field of")
def test_layout_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
self.assertInvalid("is not a valid field of")
def test_invoice_field_properties_are_set_from_the_invoice(self):
self.settings.append(
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
)
self.settings.save()
field = self.settings.invoice_fields[0]
self.assertEqual(field.label, "Customer")
self.assertEqual(field.fieldtype, "Link")
self.assertEqual(field.options, "Customer")
def test_searchable_item_field_is_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
def test_excluded_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_of_unsearchable_type_is_not_allowed(self):
# maintain stock is a Check field
self.settings.append(
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
)
self.assertInvalid("cannot be used to search items")
def test_unknown_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_without_a_fieldname_is_not_allowed(self):
# the form fills the fieldname in, it cannot be picked on its own
self.settings.append("pos_search_fields", {"field": "Description (description)"})
self.assertInvalid("cannot be used to search items")
def test_search_field_option_must_match_its_fieldname(self):
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
self.assertInvalid("does not match")
def test_bare_label_is_not_accepted_as_a_search_field(self):
# the stored option carries the fieldname, the patch backfills older rows
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
self.assertInvalid("does not match")
def test_duplicate_search_fields_are_not_allowed(self):
for _ in range(2):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.assertInvalid("has been already added")
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
row = self.settings.pos_search_fields[0].name
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
append_fieldname()
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
def test_patch_leaves_an_already_migrated_search_field_alone(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
append_fieldname()
row = self.settings.pos_search_fields[0].name
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
def test_invoice_fields_are_skipped_when_no_invoice_type_is_selected(self):
self.settings.invoice_type = None
self.settings.append("invoice_fields", {"fieldname": "customer"})
self.settings.save()
self.assertEqual(self.settings.invoice_fields[0].fieldname, "customer")

View File

@@ -142,6 +142,8 @@ def start_pcv_processing(docname: str):
@frappe.whitelist()
def pause_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
@@ -157,6 +159,8 @@ def pause_pcv_processing(docname: str):
@frappe.whitelist()
def cancel_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
@@ -171,6 +175,8 @@ def cancel_pcv_processing(docname: str):
@frappe.whitelist()
def resume_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()

View File

@@ -10,6 +10,7 @@ from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt
from erpnext.controllers.accounts_controller import merge_taxes
from erpnext.controllers.mapper import get_qty_already_mapped
@frappe.whitelist()
@@ -52,6 +53,11 @@ def make_purchase_receipt(
args = {}
args = frappe.parse_json(args)
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item")
def received_and_mapped_qty(obj):
return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
def post_parent_process(source_parent, target_parent):
remove_items_with_zero_qty(target_parent)
set_missing_values(source_parent, target_parent)
@@ -75,15 +81,13 @@ def make_purchase_receipt(
or {}
)
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
obj.conversion_factor
)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
)
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
target.qty = pending_qty - flt(returned_qty_map.get("qty"))
target.received_qty = pending_qty
target.stock_qty = (pending_qty - flt(returned_qty_map.get("qty"))) * flt(obj.conversion_factor)
target.amount = pending_qty * flt(obj.rate)
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
def select_item(d):
filtered_items = args.get("filtered_children", [])
@@ -113,7 +117,8 @@ def make_purchase_receipt(
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
"condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty)
and select_item(doc),
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",

View File

@@ -235,6 +235,9 @@ class PurchaseInvoice(BuyingController):
"overflow_type": "billing",
}
]
self.closed_source_links = [
("Purchase Invoice Item", "pr_detail", "Purchase Receipt Item", "Purchase Receipt")
]
def onload(self):
super().onload()

View File

@@ -277,6 +277,15 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
if not (entry.amount or entry.base_amount):
continue
lcv_account_currency = get_account_currency(entry.expense_account)
credit_in_transaction_currency = (
flt(entry.amount)
if lcv_account_currency == doc.currency
else flt(
entry.base_amount / doc.conversion_rate, item.precision("net_amount")
)
)
gl_dict = self.get_gl_dict(
{
"account": entry.expense_account,
@@ -285,7 +294,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(entry.base_amount),
"credit_in_account_currency": flt(entry.amount),
"credit_in_transaction_currency": item.net_amount,
"credit_in_transaction_currency": credit_in_transaction_currency,
"project": entry.dimensions.project or item.project or doc.project,
},
item=item,

View File

@@ -27,6 +27,7 @@ def start_payment_ledger_repost(docname: str | None = None):
"""
if docname:
repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
repost_doc.check_permission("submit")
if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
try:
for entry in repost_doc.repost_vouchers:

View File

@@ -278,6 +278,9 @@ class SalesInvoice(SellingController):
"overflow_type": "billing",
}
]
self.closed_source_links = [
("Sales Invoice Item", "dn_detail", "Delivery Note Item", "Delivery Note")
]
def set_indicator(self):
"""Set indicator for portal"""

View File

@@ -476,7 +476,7 @@ class SalesInvoiceGLComposer(BaseGLComposer):
for payment_mode in doc.payments:
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
payment_mode.base_amount -= flt(doc.change_amount)
payment_mode.base_amount -= flt(doc.base_change_amount)
if payment_mode.base_amount:
# POS, make payment entries

View File

@@ -1583,6 +1583,35 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_pos_change_amount_multi_currency_gl_entry(self):
from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 0)
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.currency = "USD"
si.conversion_rate = 50
si.party_account_currency = "USD"
si.account_for_change_amount = "Cash - _TC"
si.change_amount = 50
si.base_change_amount = 2500
si.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 150, "base_amount": 7500},
)
gl_entries = []
SalesInvoiceGLComposer(si).make_pos_gl_entries(gl_entries)
debtors_entry = next(entry for entry in gl_entries if entry["account"] == si.debit_to)
cash_entry = next(entry for entry in gl_entries if entry["account"] == "Cash - _TC")
self.assertEqual(flt(debtors_entry["credit"]), 5000.0)
self.assertEqual(flt(cash_entry["debit"]), 5000.0)
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
company = "_Test SDBNB Company"
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note

View File

@@ -9,6 +9,7 @@ from frappe.utils import cint, flt
from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
from erpnext.accounts.utils import get_currency_precision
def execute(filters=None):
@@ -43,6 +44,7 @@ def get_data(filters, show_party_name):
account_filter = get_accounts_with_children(filters.get("account"))
company_currency = frappe.get_cached_value("Company", filters.company, "default_currency")
precision = get_currency_precision()
opening_balances = get_opening_balances(filters, account_filter)
balances_within_period = get_balances_within_period(filters, account_filter)
@@ -65,14 +67,17 @@ def get_data(filters, show_party_name):
# opening
opening_debit, opening_credit = opening_balances.get(party.name, [0, 0])
opening_debit, opening_credit = flt(opening_debit, precision), flt(opening_credit, precision)
row.update({"opening_debit": opening_debit, "opening_credit": opening_credit})
# within period
debit, credit = balances_within_period.get(party.name, [0, 0])
debit, credit = flt(debit, precision), flt(credit, precision)
row.update({"debit": debit, "credit": credit})
# closing
closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit)
closing_debit, closing_credit = flt(closing_debit, precision), flt(closing_credit, precision)
row.update({"closing_debit": closing_debit, "closing_credit": closing_credit})
row.update({"currency": company_currency})

View File

@@ -309,6 +309,9 @@ def get_payment_entries(filters, args):
pe.mode_of_payment,
pe.project,
pe.cost_center,
pe.payment_type,
pe.source_exchange_rate,
pe.target_exchange_rate,
)
.where(
(pe.docstatus == 1)
@@ -319,6 +322,22 @@ def get_payment_entries(filters, args):
)
query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True)
payment_entries = query.run(as_dict=True)
if payment_entries:
ded = frappe.qb.DocType("Payment Entry Deduction")
deduction_totals = frappe._dict(
frappe.qb.from_(ded)
.select(ded.parent, Sum(ded.amount))
.where(ded.parent.isin([d.name for d in payment_entries]) & (ded.is_exchange_gain_loss == 0))
.groupby(ded.parent)
.run()
)
for d in payment_entries:
exchange_rate = (
d.source_exchange_rate if d.payment_type == "Receive" else d.target_exchange_rate
) or 1
d.base_grand_total = flt(d.base_grand_total) + flt(deduction_totals.get(d.name)) / exchange_rate
return payment_entries

View File

@@ -32,12 +32,13 @@ class ChildItemUpdater:
self.child_docname = child_docname
self.parent = frappe.get_doc(parent_doctype, parent_doctype_name)
self.allow_zero_qty = get_allow_zero_qty(parent_doctype)
self._ordered_items: dict | None = None
self._purchased_items: dict | None = None
self._transacted_stock_qty: dict | None = None
def update(self, trans_items: str | list) -> None:
"""Process item additions, edits, and deletions from trans_items JSON."""
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
from erpnext.buying.doctype.supplier_quotation.mapper import (
get_ordered_items as get_ordered_supplier_quotation_items,
)
from erpnext.selling.doctype.quotation.mapper import get_ordered_items
data = frappe.parse_json(trans_items)
@@ -48,11 +49,15 @@ class ChildItemUpdater:
self._check_permissions("write")
if self.parent_doctype == "Quotation":
self._ordered_items = get_ordered_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items)
self._transacted_stock_qty = get_ordered_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(
self.parent, data, self._transacted_stock_qty
)
elif self.parent_doctype == "Supplier Quotation":
self._purchased_items = get_purchased_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items)
self._transacted_stock_qty = get_ordered_supplier_quotation_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(
self.parent, data, self._transacted_stock_qty
)
else:
items_added_or_removed |= validate_and_delete_children(self.parent, data)
@@ -71,6 +76,7 @@ class ChildItemUpdater:
else:
self._check_permissions("write")
child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname"))
d["conversion_factor"] = self._get_new_conversion_factor(child_item, d)
change_state = get_child_item_change_state(self.parent_doctype, child_item, d)
rate_unchanged = change_state.rate_unchanged
@@ -78,6 +84,13 @@ class ChildItemUpdater:
if is_child_item_unchanged(change_state):
continue
if child_item.get("closed"):
frappe.throw(
_(
"Row #{0}: Cannot change item {1} because it is closed. Reopen the row first."
).format(child_item.idx, child_item.item_code)
)
self._validate_quantity_and_rate(child_item, d, rate_unchanged)
if flt(child_item.get("qty")) != flt(d.get("qty")):
@@ -251,6 +264,22 @@ class ChildItemUpdater:
item_row,
)
def _get_new_conversion_factor(self, child_item, new_data: dict) -> float:
current_factor = flt(child_item.get("conversion_factor")) or 1
uom = new_data.get("uom") or child_item.get("uom")
if uom == child_item.get("stock_uom"):
return 1
requested_factor = flt(new_data.get("conversion_factor"))
if requested_factor:
return requested_factor
if uom == child_item.get("uom"):
return current_factor
return flt(get_conversion_factor(child_item.item_code, uom).get("conversion_factor")) or 1
def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None:
if not flt(new_data.get("qty")) and not self.allow_zero_qty:
frappe.throw(
@@ -264,24 +293,24 @@ class ChildItemUpdater:
"Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")),
"Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")),
}
old_conversion_factor = flt(child_item.get("conversion_factor")) or 1
new_conversion_factor = flt(new_data.get("conversion_factor")) or old_conversion_factor
new_stock_qty = flt(new_data.get("qty")) * new_conversion_factor
if self.parent_doctype in qty_limits:
qty_field, error_message = qty_limits[self.parent_doctype]
if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
old_stock_qty = flt(child_item.get(qty_field)) * old_conversion_factor
if new_stock_qty < old_stock_qty:
frappe.throw(
_("Row #{0}:").format(new_data.get("idx")) + error_message,
title=_("Invalid Qty"),
)
if self.parent_doctype not in ("Quotation", "Supplier Quotation"):
if not self._transacted_stock_qty:
return
items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items
if not items_map:
return
qty_to_check = items_map.get(child_item.name)
if not qty_to_check:
old_stock_qty = self._transacted_stock_qty.get(child_item.name)
if not old_stock_qty:
return
if not rate_unchanged:
@@ -291,7 +320,7 @@ class ChildItemUpdater:
).format(frappe.bold(new_data.get("item_code")))
)
if flt(new_data.get("qty")) < qty_to_check:
if new_stock_qty < old_stock_qty:
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None:
@@ -458,7 +487,11 @@ def update_bin_on_delete(row, doctype: str) -> None:
def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
"""Delete child rows not present in data; return True if any were removed."""
updated_item_names = [d.get("docname") for d in data]
deleted_children = [item for item in parent.items if item.name not in updated_item_names]
# A closed row is left out of the payload rather than deleted, so its absence
# must not be read as a removal.
deleted_children = [
item for item in parent.items if item.name not in updated_item_names and not item.get("closed")
]
for d in deleted_children:
validate_child_on_delete(d, parent, ordered_item)
@@ -581,22 +614,18 @@ def update_child_item_rate_and_discount(
def update_child_item_uom_and_weight(child_item, new_data) -> None:
conv_fac_precision = child_item.precision("conversion_factor") or 2
if new_data.get("conversion_factor"):
if child_item.stock_uom == child_item.uom:
child_item.conversion_factor = 1
else:
child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision)
child_item.conversion_factor = flt(new_data.get("conversion_factor"))
if new_data.get("uom"):
child_item.uom = new_data.get("uom")
conversion_factor = flt(
get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor")
)
child_item.conversion_factor = (
flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor
)
child_item.conversion_factor = flt(new_data.get("conversion_factor")) or conversion_factor
if child_item.get("weight_per_unit"):
child_item.total_weight = flt(

View File

@@ -53,6 +53,11 @@ class TaxService:
if doc.get("taxes") or doc.get("is_pos"):
return
# set by the Opening Invoice Creation Tool, where the outstanding amount
# entered against a party is already inclusive of tax
if doc.flags.dont_auto_add_taxes:
return
if frappe.get_single_value(
"Accounts Settings", "add_taxes_from_taxes_and_charges_template"
) and hasattr(doc, "taxes_and_charges"):

View File

@@ -854,6 +854,7 @@
"added": 0,
"child": 1,
"collapsible": 1,
"filters": "{\"is_return\": 1}",
"hidden": 0,
"indent": 0,
"is_default_module": 0,
@@ -864,8 +865,7 @@
"open_in_new_tab": 0,
"route_options": "{\"is_return\": 1}",
"show_arrow": 0,
"type": "Link",
"filters": "{\"is_return\": 1}"
"type": "Link"
},
{
"added": 0,
@@ -931,6 +931,7 @@
"added": 0,
"child": 1,
"collapsible": 1,
"filters": "{\"is_return\": 1}",
"hidden": 0,
"indent": 0,
"is_default_module": 0,
@@ -941,8 +942,7 @@
"open_in_new_tab": 0,
"route_options": "{\"is_return\": 1}",
"show_arrow": 0,
"type": "Link",
"filters": "{\"is_return\": 1}"
"type": "Link"
},
{
"added": 0,
@@ -1154,20 +1154,6 @@
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 0,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 1,
"label": "Payments",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Section Break"
},
{
"added": 0,
"child": 1,
@@ -1418,27 +1404,12 @@
"child": 0,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 1,
"label": "Financial Reports",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Section Break"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"icon": "wallet",
"indent": 1,
"is_default_module": 0,
"keep_closed": 0,
"label": "Financial Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Section Break"
},
@@ -1877,7 +1848,7 @@
"type": "Link"
}
],
"modified": "2026-08-28 12:00:00.000000",
"modified": "2026-09-01 17:33:21.459367",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts",

View File

@@ -1359,21 +1359,28 @@ def get_children(
parent_fieldname = "parent_" + doctype.lower().replace(" ", "_")
fields = ["name as value", "is_group as expandable"]
filters = [["docstatus", "<", 2]]
if frappe.db.has_column(doctype, "disabled") and not include_disabled:
filters.append(["disabled", "=", False])
if frappe.db.has_column(doctype, "disabled"):
if include_disabled:
# the tree marks disabled rows, so it needs the flag
fields.append("disabled")
else:
filters.append(["disabled", "=", False])
# extra columns the tree views render as badges / clean labels
node_fields = {
"Account": ["root_type", "account_name", "account_number", "account_currency", "freeze_account"],
"Cost Center": ["cost_center_name", "cost_center_number"],
}
fields += node_fields.get(doctype, [])
if is_root:
filters.append(IfNull(Field(parent_fieldname), "") == "")
filters.append(["company", "=", company])
if doctype == "Account":
fields.append("report_type")
else:
filters.append([parent_fieldname, "=", parent])
if is_root:
fields += ["root_type", "report_type", "account_currency"] if doctype == "Account" else []
filters.append(["company", "=", company])
else:
fields += ["root_type", "account_currency"] if doctype == "Account" else []
fields += [parent_fieldname + " as parent"]
fields.append(parent_fieldname + " as parent")
acc = frappe.get_list(doctype, fields=fields, filters=filters)

View File

@@ -1,48 +0,0 @@
{
"app": "erpnext",
"creation": "2026-08-16 00:00:00.000000",
"docstatus": 0,
"doctype": "Sidebar",
"header_icon": "layers",
"idx": 0,
"items": [
{
"added": 0,
"child": 0,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Bulk Transaction Log Detail",
"link_to": "Bulk Transaction Log Detail",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 0,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Bulk Transaction Log",
"link_to": "Bulk Transaction Log",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-08-16 00:00:00.000000",
"modified_by": "Administrator",
"module": "Bulk Transaction",
"name": "Bulk Transaction",
"owner": "Administrator",
"sequence_id": 0.0,
"standard": 1,
"title": "Bulk Transaction"
}

View File

@@ -10,6 +10,7 @@ from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt, get_link_to_form
from erpnext.accounts.party import get_party_account
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.controllers.status_updater import get_allowance_for
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_item_defaults
@@ -34,12 +35,14 @@ def make_purchase_receipt(
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_order_item")
def get_max_receivable_qty(source):
tolerance = flt(get_allowance_for(source.item_code, qty_or_amount="qty")[0])
return flt(source.qty) * (100 + tolerance) / 100
def update_item(obj, target, source_parent):
received_qty = flt(obj.received_qty)
received_qty = flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
qty = flt(obj.qty)
pending_qty = qty - received_qty
@@ -84,11 +87,13 @@ def make_purchase_receipt(
},
"postprocess": update_item,
"condition": lambda doc: (
True
doc.name not in mapped_qty_by_item
if is_unit_price_row(doc)
else abs(doc.received_qty) < abs(get_max_receivable_qty(doc))
else abs(doc.received_qty) + abs(mapped_qty_by_item.get(doc.name, 0))
< abs(get_max_receivable_qty(doc))
)
and doc.delivered_by_supplier != 1
and not doc.closed
and select_item(doc),
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
@@ -148,9 +153,13 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
)
return query.run(pluck="qty")[0] or 0
mapped_qty_by_item = get_qty_already_mapped(target_doc, "po_detail")
def get_billed_and_mapped_qty(po_item_name):
return flt(get_billed_qty(po_item_name)) + flt(mapped_qty_by_item.get(po_item_name, 0))
def update_item(obj, target, source_parent):
billed_qty = flt(get_billed_qty(obj.name))
target.qty = flt(obj.qty) - billed_qty
target.qty = flt(obj.qty) - get_billed_and_mapped_qty(obj.name)
item = get_item_defaults(target.item_code, source_parent.company)
item_group = get_item_group_defaults(target.item_code, source_parent.company)
@@ -193,6 +202,8 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
or abs(doc.billed_amt) < abs(doc.amount)
or doc.qty > flt(get_billed_qty(doc.name))
)
and (doc.name not in mapped_qty_by_item or doc.qty > get_billed_and_mapped_qty(doc.name))
and not doc.closed
and select_item(doc),
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},

View File

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

View File

@@ -178,7 +178,15 @@ class PurchaseOrder(BuyingController):
"global_allowance_field": "over_order_allowance",
"global_allowance_doctype": "Buying Settings",
"item_allowance_field": "over_order_allowance",
}
},
{
"source_dt": "Purchase Order Item",
"target_dt": "Supplier Quotation Item",
"join_field": "supplier_quotation_item",
"target_field": "ordered_qty",
"target_ref_field": "stock_qty",
"source_field": "stock_qty",
},
]
def onload(self):
@@ -250,6 +258,7 @@ class PurchaseOrder(BuyingController):
["conversion_factor", "="],
],
"is_child_table": True,
"allow_duplicate_prev_row_id": True,
},
"Material Request": {
"ref_dn_field": "material_request",
@@ -402,6 +411,25 @@ class PurchaseOrder(BuyingController):
def update_status(self, status):
StatusService(self).update_status(status)
def on_item_close_status_change(self):
StatusService(self).recalculate_after_item_close()
def is_item_closable(self, item):
return flt(item.received_qty) < flt(item.qty) or super().is_item_closable(item)
def update_prevdoc_status(self):
super().update_prevdoc_status()
for supplier_quotation in {item.supplier_quotation for item in self.items}:
if not supplier_quotation:
continue
doc = frappe.get_doc("Supplier Quotation", supplier_quotation)
if doc.docstatus.is_cancelled():
frappe.throw(_("Supplier Quotation {0} is cancelled").format(supplier_quotation))
doc.set_status(update=True)
def on_submit(self):
super().on_submit()
@@ -531,7 +559,7 @@ class PurchaseOrder(BuyingController):
considering the configured over_delivery_receipt_allowance.
"""
for item in self.get("items", []):
if item.delivered_by_supplier:
if item.delivered_by_supplier or item.closed:
continue
tolerance = flt(get_allowance_for(item.item_code, qty_or_amount="qty")[0])
max_receivable_qty = flt(item.qty) * (100 + tolerance) / 100

View File

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

View File

@@ -333,6 +333,38 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(po.get("items")[0].amount, 1400)
self.assertEqual(get_ordered_qty(), existing_ordered_qty + 3)
def test_update_child_qty_with_conversion_factor_after_receipt(self):
item = make_item(uoms=[{"uom": "Box", "conversion_factor": 5}])
purchase_order = create_purchase_order(item_code=item.item_code, qty=6, do_not_save=True)
purchase_order.items[0].uom = "Box"
purchase_order.items[0].conversion_factor = 5
purchase_order.save()
purchase_order.submit()
create_pr_against_po(purchase_order.name, 2)
row = purchase_order.items[0]
trans_items = json.dumps(
[
{
"item_code": row.item_code,
"rate": row.rate,
"qty": 4,
"uom": row.uom,
"conversion_factor": 2,
"docname": row.name,
}
]
)
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot set quantity less than received quantity",
update_child_qty_rate,
"Purchase Order",
trans_items,
purchase_order.name,
)
def test_update_child_adding_new_item(self):
po = create_purchase_order(do_not_save=1)
po.items[0].qty = 4

View File

@@ -86,6 +86,7 @@
"returned_qty",
"column_break_60",
"billed_amt",
"closed",
"accounting_details",
"expense_account",
"column_break_fyqr",
@@ -557,6 +558,7 @@
"fieldname": "blanket_order_rate",
"fieldtype": "Currency",
"label": "Blanket Order Rate",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
@@ -645,6 +647,15 @@
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "closed",
"fieldtype": "Check",
"label": "Closed",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"description": "Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges",
"fieldname": "item_tax_rate",
@@ -944,7 +955,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-08-27 11:55:37.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

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

View File

@@ -8,6 +8,8 @@ from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt
from erpnext.controllers.mapper import get_qty_already_mapped
@frappe.whitelist()
def make_purchase_order(
@@ -16,6 +18,9 @@ def make_purchase_order(
if args is None:
args = {}
args = frappe.parse_json(args)
ordered_items = get_ordered_items(source_name)
mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item")
def set_missing_values(source, target):
target.run_method("set_missing_values")
@@ -23,7 +28,9 @@ def make_purchase_order(
target.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor)
balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0)
target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0
target.qty = flt(target.stock_qty) / flt(obj.conversion_factor)
def select_item(d):
filtered_items = args.get("filtered_children", [])
@@ -51,7 +58,9 @@ def make_purchase_order(
["sales_order", "sales_order"],
],
"postprocess": update_item,
"condition": select_item,
"condition": lambda item: item.name not in mapped_items
and (item.stock_qty > ordered_items.get(item.name, 0.0) or item.qty == 0)
and select_item(item),
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",
@@ -107,3 +116,14 @@ def make_quotation(source_name: str, target_doc: str | dict | Document | None =
)
return doclist
def get_ordered_items(supplier_quotation: str) -> frappe._dict:
return frappe._dict(
frappe.get_all(
"Supplier Quotation Item",
{"docstatus": 1, "parent": supplier_quotation, "ordered_qty": (">", 0)},
["name", "ordered_qty"],
as_list=True,
)
)

View File

@@ -34,7 +34,10 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
if (this.frm.doc.__islocal && !this.frm.doc.valid_till) {
this.frm.set_value("valid_till", frappe.datetime.add_months(this.frm.doc.transaction_date, 1));
}
if (this.frm.doc.docstatus === 1) {
if (
this.frm.doc.docstatus === 1 &&
!["Stopped", "Ordered", "Expired"].includes(this.frm.doc.status)
) {
this.frm.add_custom_button(
__("Purchase Order"),
this.make_purchase_order.bind(this),

View File

@@ -767,7 +767,7 @@
"no_copy": 1,
"oldfieldname": "status",
"oldfieldtype": "Select",
"options": "\nDraft\nSubmitted\nStopped\nCancelled\nExpired",
"options": "\nDraft\nSubmitted\nPartially Ordered\nOrdered\nStopped\nCancelled\nExpired",
"print_hide": 1,
"read_only": 1,
"reqd": 1,
@@ -948,7 +948,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-08-21 23:11:50.274992",
"modified": "2026-08-30 16:30:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",

View File

@@ -6,10 +6,13 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate, nowdate
from pypika.terms import ExistsCriterion
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from .mapper import get_ordered_items
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -86,7 +89,9 @@ class SupplierQuotation(BuyingController):
shipping_address: DF.Link | None
shipping_address_display: DF.TextEditor | None
shipping_rule: DF.Link | None
status: DF.Literal["", "Draft", "Submitted", "Stopped", "Cancelled", "Expired"]
status: DF.Literal[
"", "Draft", "Submitted", "Partially Ordered", "Ordered", "Stopped", "Cancelled", "Expired"
]
supplier: DF.Link
supplier_address: DF.Link | None
supplier_name: DF.Data | None
@@ -112,13 +117,17 @@ class SupplierQuotation(BuyingController):
def validate(self):
super().validate()
self.set_status()
if not self.status:
self.status = "Draft"
from erpnext.controllers.status_updater import validate_status
validate_status(self.status, ["Draft", "Submitted", "Stopped", "Cancelled"])
validate_status(
self.status,
["Draft", "Submitted", "Partially Ordered", "Ordered", "Stopped", "Cancelled", "Expired"],
)
validate_for_items(self)
self.validate_with_previous_doc()
@@ -126,11 +135,11 @@ class SupplierQuotation(BuyingController):
self.validate_valid_till()
def on_submit(self):
self.db_set("status", "Submitted")
self.set_status(update=True)
self.update_rfq_supplier_status(1)
def on_cancel(self):
self.db_set("status", "Cancelled")
self.set_status(update=True)
self.update_rfq_supplier_status(0)
def on_trash(self):
@@ -166,6 +175,24 @@ class SupplierQuotation(BuyingController):
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
frappe.throw(_("Valid till Date cannot be before Transaction Date"))
def get_ordered_status(self):
ordered_items = get_ordered_items(self.name)
if not ordered_items:
return "Submitted"
for row in self.items:
if row.name not in ordered_items or row.stock_qty > ordered_items[row.name]:
return "Partially Ordered"
return "Ordered"
def is_fully_ordered(self):
return self.get_ordered_status() == "Ordered"
def is_partially_ordered(self):
return self.get_ordered_status() == "Partially Ordered"
def update_rfq_supplier_status(self, include_me):
from frappe.query_builder.functions import Count
@@ -243,27 +270,29 @@ def get_list_context(context=None):
def set_expired_status():
# Only submitted quotations past their validity should be expired
frappe.db.set_value(
"Supplier Quotation",
{
"docstatus": 1,
"status": ["not in", ["Cancelled", "Stopped"]],
"valid_till": ["<", nowdate()],
},
"status",
"Expired",
update_modified=True,
)
supplier_quotation = frappe.qb.DocType("Supplier Quotation")
purchase_order = frappe.qb.DocType("Purchase Order")
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
def get_purchased_items(supplier_quotation: str):
return frappe._dict(
frappe.get_all(
"Purchase Order Item",
filters={"supplier_quotation": supplier_quotation, "docstatus": 1},
fields=["supplier_quotation_item", {"SUM": "qty"}],
group_by="supplier_quotation_item",
as_list=1,
purchase_order_against_quotation = (
frappe.qb.from_(purchase_order)
.from_(purchase_order_item)
.select(purchase_order.name)
.where(
(purchase_order_item.docstatus == 1)
& (purchase_order.docstatus == 1)
& (purchase_order_item.parent == purchase_order.name)
& (purchase_order_item.supplier_quotation == supplier_quotation.name)
)
)
(
frappe.qb.update(supplier_quotation)
.set(supplier_quotation.status, "Expired")
.where(
(supplier_quotation.docstatus == 1)
& (supplier_quotation.status.notin(["Expired", "Stopped"]))
& (supplier_quotation.valid_till < nowdate())
& ExistsCriterion(purchase_order_against_quotation).negate()
)
).run()

View File

@@ -1,10 +1,12 @@
frappe.listview_settings["Supplier Quotation"] = {
add_fields: ["supplier", "base_grand_total", "status", "company", "currency"],
get_indicator: function (doc) {
if (doc.status === "Ordered") {
if (doc.status === "Submitted") {
return [__("Submitted"), "blue", "status,=,Submitted"];
} else if (doc.status === "Partially Ordered") {
return [__("Partially Ordered"), "yellow", "status,=,Partially Ordered"];
} else if (doc.status === "Ordered") {
return [__("Ordered"), "green", "status,=,Ordered"];
} else if (doc.status === "Rejected") {
return [__("Lost"), "gray", "status,=,Lost"];
} else if (doc.status === "Expired") {
return [__("Expired"), "gray", "status,=,Expired"];
}

View File

@@ -15,6 +15,7 @@ from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation imp
from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import set_expired_status
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
from erpnext.patches.v16_0.set_supplier_quotation_order_status import execute as set_order_status
from erpnext.tests.assertions import assert_raises_with_savepoint
from erpnext.tests.utils import ERPNextTestSuite
@@ -23,6 +24,23 @@ class TestPurchaseOrder(ERPNextTestSuite):
def setUp(self):
self.load_test_records("Supplier Quotation")
def make_order(self, supplier_quotation, qty):
purchase_order = make_purchase_order(supplier_quotation.name)
purchase_order.naming_series = "_T-Purchase Order-"
purchase_order.items[0].qty = qty
purchase_order.items[0].schedule_date = add_days(today(), 1)
purchase_order.insert()
purchase_order.submit()
return purchase_order
def update_order_qty(self, purchase_order, qty):
item = purchase_order.items[0]
items = json.dumps(
[{"item_code": item.item_code, "rate": item.rate, "qty": qty, "docname": item.name}]
)
update_child_qty_rate("Purchase Order", items, purchase_order.name)
purchase_order.reload()
def test_valid_till_before_transaction_date_rejected(self):
rfq = make_request_for_quotation()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
@@ -44,6 +62,14 @@ class TestPurchaseOrder(ERPNextTestSuite):
valid.insert()
valid.submit()
partially_ordered = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[1].supplier)
partially_ordered.valid_till = add_days(today(), 10)
partially_ordered.items[0].qty = 10
partially_ordered.insert()
partially_ordered.submit()
partial_order = self.make_order(partially_ordered, 4)
partially_ordered.db_set("valid_till", add_days(today(), -2))
# A past-validity draft must not be expired - "Expired" applies to submitted quotations only
draft = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
draft.transaction_date = add_days(today(), -10)
@@ -54,8 +80,24 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(frappe.db.get_value("Supplier Quotation", expired.name, "status"), "Expired")
self.assertEqual(frappe.db.get_value("Supplier Quotation", valid.name, "status"), "Submitted")
self.assertEqual(
frappe.db.get_value("Supplier Quotation", partially_ordered.name, "status"),
"Partially Ordered",
)
self.assertEqual(frappe.db.get_value("Supplier Quotation", draft.name, "status"), "Draft")
partial_order.cancel()
self.assertEqual(
frappe.db.get_value("Supplier Quotation", partially_ordered.name, "status"),
"Submitted",
)
set_expired_status()
self.assertEqual(
frappe.db.get_value("Supplier Quotation", partially_ordered.name, "status"),
"Expired",
)
def test_submit_and_cancel_updates_rfq_quote_status(self):
rfq = make_request_for_quotation()
supplier_row = rfq.suppliers[0]
@@ -73,6 +115,150 @@ class TestPurchaseOrder(ERPNextTestSuite):
"Pending",
)
def test_purchase_order_updates_order_status(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.submit()
self.assertEqual(supplier_quotation.status, "Submitted")
partial_order = self.make_order(supplier_quotation, 4)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
self.assertEqual(supplier_quotation.items[0].ordered_qty, 4)
self.update_order_qty(partial_order, 10)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Ordered")
self.assertEqual(supplier_quotation.items[0].ordered_qty, 10)
self.update_order_qty(partial_order, 4)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
complete_order = self.make_order(supplier_quotation, 6)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Ordered")
complete_order.cancel()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
partial_order.cancel()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Submitted")
self.assertEqual(supplier_quotation.items[0].ordered_qty, 0)
def test_purchase_order_maps_remaining_quotation_qty(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.submit()
self.make_order(supplier_quotation, 4)
purchase_order = make_purchase_order(supplier_quotation.name)
self.assertEqual(purchase_order.items[0].qty, 6)
purchase_order.items[0].schedule_date = add_days(today(), 1)
purchase_order.submit()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Ordered")
self.assertEqual(make_purchase_order(supplier_quotation.name).items, [])
def test_purchase_order_skips_fully_ordered_quotation_rows(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.append("items", {"item_code": "_Test Item 2", "qty": 3, "rate": 100})
supplier_quotation.submit()
first_order = make_purchase_order(
supplier_quotation.name,
args={"filtered_children": [supplier_quotation.items[0].name]},
)
first_order.items[0].schedule_date = add_days(today(), 1)
first_order.submit()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
second_order = make_purchase_order(supplier_quotation.name)
self.assertEqual(len(second_order.items), 1)
self.assertEqual(second_order.items[0].item_code, "_Test Item 2")
self.assertEqual(second_order.items[0].qty, 3)
second_order.items[0].schedule_date = add_days(today(), 1)
second_order.submit()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Ordered")
def test_purchase_order_cannot_exceed_supplier_quotation_qty(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.items[0].qty = 5
supplier_quotation.submit()
first_order = make_purchase_order(supplier_quotation.name)
second_order = make_purchase_order(supplier_quotation.name)
for purchase_order in (first_order, second_order):
purchase_order.items[0].schedule_date = add_days(today(), 1)
first_order.submit()
self.assertRaises(frappe.ValidationError, second_order.submit)
def test_removing_purchase_order_item_updates_quotation_status(self):
first_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
first_quotation.submit()
second_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
second_item = second_quotation.items[0]
second_item.item_code = second_item.item_name = "_Test Item 2"
second_item.stock_uom = second_item.uom = frappe.db.get_value("Item", "_Test Item 2", "stock_uom")
second_item.conversion_factor = 1
second_quotation.submit()
purchase_order = make_purchase_order(first_quotation.name)
purchase_order = make_purchase_order(second_quotation.name, purchase_order)
purchase_order.naming_series = "_T-Purchase Order-"
for item in purchase_order.items:
item.schedule_date = add_days(today(), 1)
purchase_order.insert()
purchase_order.submit()
first_quotation.reload()
second_quotation.reload()
self.assertEqual(first_quotation.status, "Ordered")
self.assertEqual(second_quotation.status, "Ordered")
remaining_item = next(
item for item in purchase_order.items if item.supplier_quotation == second_quotation.name
)
update_child_qty_rate(
"Purchase Order",
json.dumps(
[
{
"item_code": remaining_item.item_code,
"rate": remaining_item.rate,
"qty": remaining_item.qty,
"docname": remaining_item.name,
}
]
),
purchase_order.name,
)
first_quotation.reload()
second_quotation.reload()
self.assertEqual(first_quotation.status, "Submitted")
self.assertEqual(second_quotation.status, "Ordered")
def test_order_status_patch_updates_existing_quotation(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.submit()
self.make_order(supplier_quotation, 4)
supplier_quotation.db_set("status", "Submitted")
frappe.db.set_value("Supplier Quotation Item", supplier_quotation.items[0].name, "ordered_qty", 0)
set_order_status()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
self.assertEqual(supplier_quotation.items[0].ordered_qty, 4)
def test_update_child_supplier_quotation_add_item(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit()
@@ -127,6 +313,52 @@ class TestPurchaseOrder(ERPNextTestSuite):
frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
)
def test_update_child_qty_with_conversion_factor_after_purchase(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(uoms=[{"uom": "Box", "conversion_factor": 5}])
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.items[0].item_code = item.item_code
supplier_quotation.items[0].qty = 6
supplier_quotation.items[0].uom = "Box"
supplier_quotation.items[0].conversion_factor = 5
supplier_quotation.insert()
supplier_quotation.submit()
purchase_order = make_purchase_order(supplier_quotation.name)
purchase_order.schedule_date = add_days(today(), 1)
purchase_order.items[0].qty = 2
purchase_order.save()
purchase_order.submit()
def update_qty(qty):
row = supplier_quotation.items[0]
trans_items = json.dumps(
[
{
"item_code": row.item_code,
"rate": row.rate,
"qty": qty,
"uom": row.uom,
"conversion_factor": 2,
"docname": row.name,
}
]
)
update_child_qty_rate("Supplier Quotation", trans_items, supplier_quotation.name)
update_qty(5)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.items[0].conversion_factor, 2)
self.assertEqual(supplier_quotation.items[0].stock_qty, 10)
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot reduce quantity than ordered or purchased quantity",
update_qty,
4,
)
def test_update_supplier_quotation_child_remove_item(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit()

View File

@@ -230,6 +230,17 @@
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "ordered_qty",
"fieldtype": "Float",
"hidden": 1,
"label": "Ordered Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1,
"reqd": 1
},
{
"fieldname": "base_price_list_rate",
"fieldtype": "Currency",
@@ -615,7 +626,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-08-31 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",

View File

@@ -44,6 +44,7 @@ class SupplierQuotationItem(Document):
material_request_item: DF.Data | None
net_amount: DF.Currency
net_rate: DF.Currency
ordered_qty: DF.Float
page_break: DF.Check
parent: DF.Data
parentfield: DF.Data

View File

@@ -96,6 +96,17 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
],
default: "Submitted",
},
{
fieldname: "order_status",
label: __("Order Status"),
fieldtype: "Select",
options: [
{ label: "", value: "" },
{ label: __("Not Ordered"), value: "Not Ordered" },
{ label: __("Partially Ordered"), value: "Partially Ordered" },
{ label: __("Ordered"), value: "Ordered" },
],
},
{
fieldtype: "Check",
label: __("Include Expired"),

View File

@@ -52,6 +52,7 @@ def get_data(filters):
sq_item.request_for_quotation,
sq_item.lead_time_days,
sq.supplier.as_("supplier_name"),
sq.status.as_("supplier_quotation_status"),
sq.valid_till,
)
.where(
@@ -82,6 +83,11 @@ def get_data(filters):
if filters.get("supplier"):
query = query.where(sq.supplier.isin(filters.get("supplier")))
if filters.get("order_status") == "Not Ordered":
query = query.where(sq.status.notin(["Partially Ordered", "Ordered"]))
elif filters.get("order_status"):
query = query.where(sq.status == filters.get("order_status"))
if not filters.get("include_expired"):
query = query.where(sq.status != "Expired")
@@ -109,6 +115,7 @@ def prepare_data(supplier_quotation_data, filters):
else data.get("item_code"), # leave blank if group by field
"supplier_name": "" if group_by_field == "supplier_name" else data.get("supplier_name"),
"quotation": data.get("parent"),
"order_status": get_order_status(data.get("supplier_quotation_status")),
"qty": data.get("qty"),
"price": flt(data.get("amount"), float_precision),
"uom": data.get("uom"),
@@ -164,6 +171,10 @@ def prepare_data(supplier_quotation_data, filters):
return out, chart_data
def get_order_status(status):
return status if status in ("Partially Ordered", "Ordered") else "Not Ordered"
def prepare_chart_data(suppliers, qty_list, supplier_qty_price_map):
data_points_map = {}
qty_list.sort()
@@ -265,6 +276,12 @@ def get_columns(filters):
"options": "Supplier Quotation",
"width": 200,
},
{
"fieldname": "order_status",
"label": _("Order Status"),
"fieldtype": "Data",
"width": 130,
},
{"fieldname": "valid_till", "label": _("Valid Till"), "fieldtype": "Date", "width": 100},
{
"fieldname": "lead_time_days",

View File

@@ -2,7 +2,9 @@
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order
from erpnext.buying.report.supplier_quotation_comparison.supplier_quotation_comparison import execute
from erpnext.tests.utils import ERPNextTestSuite
@@ -38,6 +40,15 @@ class TestSupplierQuotationComparison(ERPNextTestSuite):
filters.update(extra)
return execute(filters)[1]
def make_order(self, supplier_quotation, qty):
purchase_order = make_purchase_order(supplier_quotation.name)
purchase_order.naming_series = "_T-Purchase Order-"
purchase_order.items[0].qty = qty
purchase_order.items[0].schedule_date = add_days(today(), 1)
purchase_order.insert()
purchase_order.submit()
return purchase_order
def test_no_filters_returns_empty(self):
self.assertEqual(execute(None)[1], [])
@@ -83,3 +94,34 @@ class TestSupplierQuotationComparison(ERPNextTestSuite):
both = names(status="")
self.assertIn(draft.name, both)
self.assertIn(submitted.name, both)
def test_order_status_and_filter(self):
supplier_quotation = self.make_quotation("_Test Supplier", qty=10, rate=100)
def get_order_status():
return next(
row["order_status"]
for row in self.run_report(item_code=ITEM)
if row["quotation"] == supplier_quotation.name
)
def quotations_with_status(order_status):
return {row["quotation"] for row in self.run_report(item_code=ITEM, order_status=order_status)}
self.assertEqual(get_order_status(), "Not Ordered")
self.assertIn(supplier_quotation.name, quotations_with_status("Not Ordered"))
partial_order = self.make_order(supplier_quotation, qty=4)
self.assertEqual(get_order_status(), "Partially Ordered")
self.assertIn(supplier_quotation.name, quotations_with_status("Partially Ordered"))
self.assertNotIn(supplier_quotation.name, quotations_with_status("Ordered"))
complete_order = self.make_order(supplier_quotation, qty=6)
self.assertEqual(get_order_status(), "Ordered")
self.assertIn(supplier_quotation.name, quotations_with_status("Ordered"))
complete_order.cancel()
self.assertEqual(get_order_status(), "Partially Ordered")
partial_order.cancel()
self.assertEqual(get_order_status(), "Not Ordered")

View File

@@ -1,6 +1,6 @@
{
"app": "erpnext",
"creation": "2026-08-16 00:00:00.000000",
"creation": "2026-08-16 00:00:00",
"docstatus": 0,
"doctype": "Sidebar",
"header_icon": "shopping-cart",
@@ -283,97 +283,6 @@
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 0,
"collapsible": 1,
"hidden": 0,
"icon": "rocket",
"indent": 1,
"is_default_module": 0,
"keep_closed": 1,
"label": "Subcontracting",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"icon": "folder-tree",
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Subcontracting BOM",
"link_to": "Subcontracting BOM",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Subcontracting Inward Order",
"link_to": "Subcontracting Inward Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Subcontracting Delivery",
"link_to": "Stock Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Subcontracting Order",
"link_to": "Subcontracting Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Subcontracting Receipt",
"link_to": "Subcontracting Receipt",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 0,
@@ -631,12 +540,11 @@
"type": "Link"
}
],
"modified": "2026-08-16 00:00:00.000000",
"modified": "2026-09-01 17:37:29.971719",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying",
"owner": "Administrator",
"sequence_id": 0.0,
"standard": 1,
"title": "Buying"
}

View File

@@ -1,33 +0,0 @@
{
"app": "erpnext",
"creation": "2026-08-16 00:00:00.000000",
"docstatus": 0,
"doctype": "Sidebar",
"header_icon": "messages-square",
"idx": 0,
"items": [
{
"added": 0,
"child": 0,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Communication Medium",
"link_to": "Communication Medium",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-08-16 00:00:00.000000",
"modified_by": "Administrator",
"module": "Communication",
"name": "Communication",
"owner": "Administrator",
"sequence_id": 0.0,
"standard": 1,
"title": "Communication"
}

View File

@@ -39,6 +39,7 @@ from erpnext.accounts.utils import (
get_advance_payment_doctypes as _get_advance_payment_doctypes,
)
from erpnext.accounts.utils import get_fiscal_year, validate_fiscal_year
from erpnext.controllers.item_close import clear_closed_rows_on_amend
from erpnext.controllers.print_settings import (
set_print_templates_for_item_table,
set_print_templates_for_taxes,
@@ -227,7 +228,23 @@ class AccountsController(TransactionBase):
return False
def is_item_closable(self, item):
"""A row can be closed while anything is still pending on it.
Billing is the axis every closable document shares; the order doctypes
extend this with their own fulfilment axis.
Amounts are compared as magnitudes so that return rows stay closable.
That is deliberate: writing off a credit note that will never be issued
is a real decision, and closing a whole return document is already
allowed. Leaving it to the sign of the amount would decide it by
accident.
"""
return abs(flt(item.billed_amt)) < abs(flt(item.amount))
def validate(self):
clear_closed_rows_on_amend(self)
if not self.get("is_return") and not self.get("is_debit_note"):
self.validate_qty_is_not_zero()

View File

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

View File

@@ -13,6 +13,8 @@ services it orchestrates. The whitelisted ``show_*_preview`` entry points stay o
import frappe
from erpnext import get_company_currency
def get_accounting_ledger_preview(doc, filters):
from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns
@@ -44,7 +46,7 @@ def get_accounting_ledger_preview(doc, filters):
columns = get_gl_columns(filters)
gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields)
gl_columns = get_columns(columns, fields)
gl_columns = get_columns(columns, fields, get_company_currency(filters.company))
gl_data = get_data(fields, gl_entries)
finally:
frappe.db.rollback(save_point="ledger_preview")
@@ -92,7 +94,7 @@ def get_stock_ledger_preview(doc, filters):
columns = get_sl_columns(filters)
sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields)
sl_columns = get_columns(columns, columns_fields)
sl_columns = get_columns(columns, columns_fields, get_company_currency(filters.company))
sl_data = get_data(columns_fields, sl_entries)
finally:
frappe.db.rollback(save_point="ledger_preview")
@@ -113,7 +115,8 @@ def get_sl_entries_for_preview(doctype, docname, fields):
entry["out_qty"] = abs(entry.actual_qty)
entry["in_qty"] = 0
entry["in_out_rate"] = entry["valuation_rate"]
if entry.actual_qty < 0:
entry["in_out_rate"] = entry.stock_value_difference / entry.actual_qty
return sl_entries
@@ -122,12 +125,23 @@ def get_gl_entries_for_preview(doctype, docname, fields):
return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields)
def get_columns(raw_columns, fields):
return [
{"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")}
for d in raw_columns
if not d.get("hidden") and d.get("fieldname") in fields
]
def get_columns(raw_columns, fields, currency):
columns = []
for source_column in raw_columns:
if source_column.get("hidden") or source_column.get("fieldname") not in fields:
continue
column = {
"name": source_column.get("label"),
"editable": False,
"width": 110,
"fieldtype": source_column.get("fieldtype"),
}
if column["fieldtype"] == "Currency":
column["options"] = currency
columns.append(column)
return columns
def get_data(raw_columns, raw_data):

View File

@@ -0,0 +1,25 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt
def get_qty_already_mapped(target_doc, ref_field: str, qty_field: str = "qty") -> frappe._dict:
"""Return a map: {source row name: qty} of rows already mapped into the target document.
"Get Items From" passes the in-progress (unsaved) document back as `target_doc`. Its rows
are invisible to the pending-qty queries in the mappers, which only count submitted
documents -- so without this, selecting the same source document twice maps every row
again. Rows are keyed by `ref_field` (dn_detail, so_detail, ...), and a row is present in
the map even when its qty is 0, so mappers without qty tracking can dedupe on presence.
"""
if isinstance(target_doc, str):
target_doc = frappe.parse_json(target_doc)
qty_map = frappe._dict()
for row in (target_doc and target_doc.get("items")) or []:
if ref := row.get(ref_field):
qty_map[ref] = qty_map.get(ref, 0) + flt(row.get(qty_field))
return qty_map

View File

@@ -801,7 +801,7 @@ def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, pag
bo = frappe.qb.DocType("Blanket Order")
bo_item = frappe.qb.DocType("Blanket Order Item")
blanket_orders = (
query = (
frappe.qb.from_(bo)
.from_(bo_item)
.select(bo.name)
@@ -814,10 +814,12 @@ def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, pag
& (bo.company == filters.get("company"))
& (bo.docstatus == 1)
)
.run()
)
return blanket_orders
if currency := filters.get("currency"):
query = query.where(bo.currency == currency)
return query.run()
@frappe.whitelist()

View File

@@ -8,6 +8,8 @@ from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import comma_or, flt, get_link_to_form, getdate, now, nowdate, safe_div
from erpnext.controllers.item_close import closed_rows_settle, has_closable_items
class OverAllowanceError(frappe.ValidationError):
pass
@@ -40,6 +42,14 @@ status_map = {
["Ordered", "is_fully_ordered"],
["Cancelled", "eval:self.docstatus==2"],
],
"Supplier Quotation": [
["Draft", None],
["Submitted", "eval:self.docstatus==1"],
["Stopped", "eval:self.status=='Stopped'"],
["Partially Ordered", "is_partially_ordered"],
["Ordered", "is_fully_ordered"],
["Cancelled", "eval:self.docstatus==2"],
],
"Sales Order": [
["Draft", None],
[
@@ -192,9 +202,60 @@ class StatusUpdater(Document):
self.db_set("status", "Cancelled")
def update_prevdoc_status(self):
self.validate_closed_source_items()
self.update_qty()
self.validate_qty()
def get_closed_source_links(self):
"""Row links that must not point at a closed source row.
`status_updater` covers documents whose progress it already tracks.
Delivery Note and Purchase Receipt are billed through their own services
instead, so their invoices declare the link in `closed_source_links`.
"""
links = [
(args["source_dt"], args["join_field"], args["target_dt"], args["target_parent_dt"])
for args in self.status_updater
if args.get("target_dt")
and args.get("target_parent_dt")
and has_closable_items(args["target_parent_dt"])
]
return links + list(getattr(self, "closed_source_links", []))
def validate_closed_source_items(self):
"""Block submitting against rows that were closed on the source document."""
if self.docstatus != 1:
return
for source_dt, join_field, target_dt, target_parent_dt in self.get_closed_source_links():
if not frappe.get_meta(target_dt).has_field("closed"):
continue
row_idx = {}
for d in self.get_all_children(source_dt):
if d.get(join_field):
row_idx[d.get(join_field)] = d.idx
if not row_idx:
continue
closed_rows = frappe.get_all(
target_dt,
filters={"name": ("in", list(row_idx)), "closed": 1},
fields=["name", "item_code", "parent"],
)
for row in closed_rows:
frappe.throw(
_("Row #{0}: Item {1} is closed in {2} {3} and cannot be processed further").format(
row_idx[row.name],
frappe.bold(row.item_code),
_(target_parent_dt),
frappe.bold(row.parent),
)
)
def set_status(self, update=False, status=None, update_modified=True):
if self.is_new():
if self.get("amended_from"):
@@ -484,6 +545,7 @@ class StatusUpdater(Document):
if args["source_dt"] != "Pick List Item" and args["target_dt"] not in [
"Quotation Item",
"Supplier Quotation Item",
"Packed Item",
]:
if args.get("target_dt") == "Material Request Item":
@@ -605,16 +667,28 @@ class StatusUpdater(Document):
@staticmethod
def _calculate_target_parent_percentage(
name, target_parent_dt, target_dt, target_ref_field, target_field, exclude_field=None
name,
target_parent_dt,
target_dt,
target_ref_field,
target_field,
target_parent_field=None,
exclude_field=None,
):
filters = {"parent": name, "parenttype": target_parent_dt}
if exclude_field:
filters[exclude_field] = 0
tracks_closed_rows = closed_rows_settle(target_parent_dt, target_dt, target_parent_field)
fields = [target_ref_field, target_field]
if tracks_closed_rows:
fields.append("closed")
child_records = frappe.get_all(
target_dt,
filters=filters,
fields=[target_ref_field, target_field],
fields=fields,
)
if exclude_field and not child_records:
@@ -623,13 +697,19 @@ class StatusUpdater(Document):
# For operator dicts, the alias is in the "as" key; for strings, use the field name directly
ref_key = target_ref_field.get("as") if isinstance(target_ref_field, dict) else target_ref_field
sum_ref = sum(abs(record[ref_key]) for record in child_records)
# A closed row is written off, so it leaves the denominator rather than
# counting as done. The percentage stays a true measure of what was
# actually received, delivered or billed against what is still expected.
# Once every row is written off there is nothing left to measure against,
# so fall back to the whole table and report what actually happened.
open_records = [r for r in child_records if not (tracks_closed_rows and r["closed"])]
basis = open_records or child_records
sum_ref = sum(abs(record[ref_key]) for record in basis)
if sum_ref > 0:
percentage = round(
sum(min(abs(record[target_field]), abs(record[ref_key])) for record in child_records)
/ sum_ref
* 100,
sum(min(abs(record[target_field]), abs(record[ref_key])) for record in basis) / sum_ref * 100,
6,
)
else:
@@ -678,6 +758,7 @@ class StatusUpdater(Document):
args["target_dt"],
args["target_ref_field"],
args["target_field"],
args["target_parent_field"],
args.get("exclude_field"),
)
# update field

View File

@@ -337,8 +337,12 @@ class StockController(AccountsController):
items = frappe.get_all(
"Delivery Note Item",
filters={"parent": self.name, "parenttype": "Delivery Note"},
fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt"],
fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt", "closed"],
)
# A written off row leaves the basis. Once every row is written off there is
# nothing left to measure against, so fall back to the whole table.
items = [item for item in items if not item.closed] or items
total_amount = sum(flt(item.amount) for item in items)
total_returned = sum(flt(item.returned_qty) * flt(item.rate) for item in items)
# Preserve the original amount basis once the entire Delivery Note is returned.
@@ -637,7 +641,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
from erpnext.controllers.ledger_preview import get_stock_ledger_preview
filters = frappe._dict(company=company)
filters = frappe._dict(company=company, valuation_field_type="Currency")
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission("read")
doc.run_method("before_sl_preview")

View File

@@ -151,7 +151,7 @@ class SubcontractingController(StockController):
).format(item.idx, get_link_to_form("Item", item.item_code))
)
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
if not is_sub_contracted_item:
frappe.throw(
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
@@ -1248,10 +1248,10 @@ class SubcontractingController(StockController):
total_amt = sum(
flt(item.amount)
for item in self.get("items")
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
if not item.get("secondary_item_type") and not item.get("valuation_type")
)
for item in self.items:
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
item.additional_cost_per_qty = (
(item.amount * self.total_additional_costs) / total_amt
) / item.qty
@@ -1259,15 +1259,15 @@ class SubcontractingController(StockController):
total_qty = sum(
flt(item.qty)
for item in self.get("items")
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
if not item.get("secondary_item_type") and not item.get("valuation_type")
)
additional_cost_per_qty = self.total_additional_costs / total_qty
for item in self.items:
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
item.additional_cost_per_qty = additional_cost_per_qty
else:
for item in self.items:
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
item.additional_cost_per_qty = 0
@frappe.whitelist()

View File

@@ -4,7 +4,6 @@ import frappe
from frappe import _, bold
from frappe.query_builder import Case
from frappe.utils import flt, get_link_to_form
from pypika.terms import ValueWrapper
from erpnext.stock.serial_batch_bundle import get_serial_batch_list_from_item
@@ -243,7 +242,7 @@ class SubcontractingInwardController:
for item in self.get("items")
if not item.is_finished_item
and not item.secondary_item_type
and not item.is_legacy_scrap_item
and not item.valuation_type
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
]
@@ -380,7 +379,7 @@ class SubcontractingInwardController:
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
for item in self.items:
if (
item.is_finished_item or item.secondary_item_type or item.is_legacy_scrap_item
item.is_finished_item or item.secondary_item_type or item.valuation_type
) and item.valuation_rate == 0:
item.allow_zero_valuation_rate = 1
@@ -480,7 +479,7 @@ class SubcontractingInwardController:
self.validate_delivery_on_save()
else:
for item in self.items:
if not item.secondary_item_type and not item.is_legacy_scrap_item:
if not item.secondary_item_type and not item.valuation_type:
delivered_qty, returned_qty = frappe.get_value(
"Subcontracting Inward Order Item",
item.scio_detail,
@@ -509,21 +508,13 @@ class SubcontractingInwardController:
)
table = frappe.qb.DocType("Subcontracting Inward Order Item")
allowed_qty = table.produced_qty
if not allow_delivery_of_overproduced_qty:
allowed_qty = Case().when(table.produced_qty < table.qty, table.produced_qty).else_(table.qty)
query = (
frappe.qb.from_(table)
.select(
(
Case()
.when(
# bool() so the literal renders as true/false; postgres rejects `OR <integer>`
(table.produced_qty < table.qty)
| ValueWrapper(bool(allow_delivery_of_overproduced_qty)),
table.produced_qty,
)
.else_(table.qty)
- table.delivered_qty
).as_("max_allowed_qty")
)
.select((allowed_qty - table.delivered_qty).as_("max_allowed_qty"))
.where((table.name == item.scio_detail) & (table.docstatus == 1))
)
max_allowed_qty = query.run(pluck="max_allowed_qty")
@@ -550,7 +541,7 @@ class SubcontractingInwardController:
bold(
frappe.get_cached_value(
"Subcontracting Inward Order Item"
if not item.secondary_item_type and not item.is_legacy_scrap_item
if not item.secondary_item_type and not item.valuation_type
else "Subcontracting Inward Order Secondary Item",
item.scio_detail,
"stock_uom",
@@ -602,7 +593,7 @@ class SubcontractingInwardController:
)
for item in [item for item in self.items if not item.is_finished_item]:
if item.secondary_item_type or item.is_legacy_scrap_item:
if item.secondary_item_type or item.valuation_type:
scio_secondary_item = frappe.get_value(
"Subcontracting Inward Order Secondary Item",
{
@@ -661,7 +652,7 @@ class SubcontractingInwardController:
for item in self.items:
doctype = (
"Subcontracting Inward Order Item"
if not item.secondary_item_type and not item.is_legacy_scrap_item
if not item.secondary_item_type and not item.valuation_type
else "Subcontracting Inward Order Secondary Item"
)
qty_map[doctype][item.scio_detail] += (
@@ -802,7 +793,7 @@ class SubcontractingInwardController:
items = [
item
for item in self.items
if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item
if not item.is_finished_item and not item.secondary_item_type and not item.valuation_type
]
if not items:
return
@@ -913,7 +904,7 @@ class SubcontractingInwardController:
def update_inward_order_secondary_items(self):
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
secondary_items_list = [
item for item in self.items if item.secondary_item_type or item.is_legacy_scrap_item
item for item in self.items if item.secondary_item_type or item.valuation_type
]
secondary_items = defaultdict(float)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import json
import frappe
from frappe.utils import add_days, nowdate
from erpnext.accounts.services.child_item_update import update_child_qty_rate
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.controllers.item_close import update_closed_status
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "_Test Warehouse - _TC"
class TestUpdateItemsWithClosedRows(ERPNextTestSuite):
def setUp(self):
self.first_item = make_item(properties={"is_stock_item": 1}).name
self.second_item = make_item(properties={"is_stock_item": 1}).name
def make_purchase_order(self):
po = create_purchase_order(item_code=self.first_item, qty=10, rate=100, do_not_save=True)
po.append(
"items",
{
"item_code": self.second_item,
"warehouse": WAREHOUSE,
"qty": 10,
"rate": 100,
"schedule_date": add_days(nowdate(), 1),
},
)
po.set_missing_values()
po.insert()
po.submit()
update_closed_status("Purchase Order", po.name, [po.items[1].name], 1)
po.reload()
return po
def as_payload(self, rows, **overrides):
return json.dumps(
[
{
"docname": row.name,
"item_code": row.item_code,
"qty": overrides.get(row.name, row.qty),
"rate": row.rate,
"uom": row.uom,
"conversion_factor": row.conversion_factor,
"description": row.description,
"schedule_date": str(row.schedule_date),
}
for row in rows
]
)
def test_payload_without_the_closed_row_does_not_delete_it(self):
"""The dialog omits closed rows, and absence must not read as removal."""
po = self.make_purchase_order()
open_row, closed_row = po.items[0], po.items[1]
update_child_qty_rate("Purchase Order", self.as_payload([open_row], **{open_row.name: 15}), po.name)
po.reload()
self.assertEqual(len(po.items), 2)
self.assertEqual(po.items[0].qty, 15)
self.assertTrue(po.items[1].closed)
self.assertEqual(po.items[1].name, closed_row.name)
def test_closed_row_cannot_be_changed_through_the_api(self):
"""The dialog hides closed rows, but the whitelisted call is the real gate."""
po = self.make_purchase_order()
closed_row = po.items[1]
self.assertRaises(
frappe.ValidationError,
update_child_qty_rate,
"Purchase Order",
self.as_payload(po.items, **{closed_row.name: 99}),
po.name,
)
po.reload()
self.assertEqual(po.items[1].qty, 10)
def test_unchanged_closed_row_in_the_payload_is_tolerated(self):
"""A caller sending the whole table untouched should not be rejected."""
po = self.make_purchase_order()
update_child_qty_rate("Purchase Order", self.as_payload(po.items), po.name)
po.reload()
self.assertEqual(len(po.items), 2)
self.assertTrue(po.items[1].closed)

View File

@@ -0,0 +1,23 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import frappe
from erpnext.controllers.ledger_preview import get_sl_entries_for_preview
from erpnext.tests.utils import ERPNextTestSuite
class TestLedgerPreview(ERPNextTestSuite):
def test_in_out_rate_is_only_set_for_outgoing_entries(self):
stock_ledger_entries = [
frappe._dict(actual_qty=5, stock_value_difference=10),
frappe._dict(actual_qty=-5, stock_value_difference=-15),
]
with patch("frappe.get_all", return_value=stock_ledger_entries):
entries = get_sl_entries_for_preview("Delivery Note", "DN-0001", [])
self.assertIsNone(entries[0].get("in_out_rate"))
self.assertEqual(entries[1].in_out_rate, 3)

View File

@@ -30,6 +30,97 @@ class TestMapper(ERPNextTestSuite):
src_items = item_list_1 + item_list_2 + item_list_3
self.assertEqual(set(d for d in src_items), set(d.item_code for d in updated_so.items))
def test_get_items_from_is_idempotent(self):
"""Selecting the same source document twice must not duplicate rows in the target.
"Get Items From" hands the in-progress document back to the mapper as `target_doc`.
Its rows are unsaved, so the mappers' pending-qty queries (submitted documents only)
cannot see them -- every mapper has to discount them explicitly.
"""
for label, make_source, method in self.idempotency_cases():
with self.subTest(label):
source = make_source()
target = frappe.get_attr(method)(source.name)
mapped_rows = len(target.items)
self.assertTrue(mapped_rows, f"{label}: nothing was mapped")
target = frappe.get_attr(method)(source.name, target)
self.assertEqual(len(target.items), mapped_rows, f"{label}: rows were duplicated")
def idempotency_cases(self):
"""(label, source factory, mapper method) for every "Get Items From" button.
Quotation -> Sales Invoice is absent: Sales Invoice Item keeps no reference to the
Quotation row, so there is nothing to deduplicate on.
"""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.material_request.test_material_request import (
make_material_request_for_items,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
self.load_test_records("Supplier Quotation")
def make_supplier_quotation():
return frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]).submit()
return [
(
"Quotation -> Sales Order",
lambda: make_quotation(),
"erpnext.selling.doctype.quotation.mapper.make_sales_order",
),
(
"Sales Order -> Sales Invoice",
lambda: make_sales_order(),
"erpnext.selling.doctype.sales_order.mapper.make_sales_invoice",
),
(
"Sales Order -> Delivery Note",
lambda: make_sales_order(),
"erpnext.selling.doctype.sales_order.mapper.make_delivery_note",
),
(
"Delivery Note -> Sales Invoice",
lambda: create_delivery_note(),
"erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
),
(
"Material Request -> Purchase Order",
lambda: make_material_request_for_items(["_Test Item"]),
"erpnext.stock.doctype.material_request.mapper.make_purchase_order",
),
(
"Supplier Quotation -> Purchase Order",
make_supplier_quotation,
"erpnext.buying.doctype.supplier_quotation.mapper.make_purchase_order",
),
(
"Purchase Order -> Purchase Receipt",
lambda: create_purchase_order(),
"erpnext.buying.doctype.purchase_order.mapper.make_purchase_receipt",
),
(
"Purchase Order -> Purchase Invoice",
lambda: create_purchase_order(),
"erpnext.buying.doctype.purchase_order.mapper.make_purchase_invoice",
),
(
"Purchase Receipt -> Purchase Invoice",
lambda: make_purchase_receipt(),
"erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice",
),
(
"Purchase Invoice -> Purchase Receipt",
lambda: make_purchase_invoice(),
"erpnext.accounts.doctype.purchase_invoice.mapper.make_purchase_receipt",
),
]
def make_quotation(self, item_list, customer):
qtn = frappe.get_doc(
{

View File

@@ -38,6 +38,7 @@ def get_contract_template(template_name: str, doc: str | dict | Document):
doc = frappe.parse_json(doc)
contract_template = frappe.get_doc("Contract Template", template_name)
contract_template.check_permission()
contract_terms = None
if contract_template.contract_terms:

View File

@@ -29,12 +29,19 @@ class EmailCampaign(Document):
def validate(self):
self.set_date()
# checking if email is set for lead. Not checking for contact as email is a mandatory field for contact.
if self.email_campaign_for == "Lead":
self.validate_lead()
self.validate_recipient_email()
self.validate_email_campaign_already_exists()
self.update_status()
def validate_recipient_email(self):
if not self.recipient:
return
if self.email_campaign_for == "Lead":
self.validate_lead()
elif self.email_campaign_for == "Contact":
self.validate_contact()
def set_date(self):
if getdate(self.start_date) < getdate(today()):
frappe.throw(_("Start Date cannot be before the current date"))
@@ -56,6 +63,13 @@ class EmailCampaign(Document):
lead_name = frappe.db.get_value("Lead", self.recipient, "lead_name")
frappe.throw(_("Please set an email id for the Lead {0}").format(lead_name))
def validate_contact(self):
contact = frappe.db.get_value("Contact", self.recipient, ["email_id", "full_name"], as_dict=True)
if contact and not contact.email_id:
frappe.throw(
_("Please set a primary email ID for the Contact {0}").format(frappe.bold(contact.full_name))
)
def validate_email_campaign_already_exists(self):
email_campaign_exists = frappe.db.exists(
"Email Campaign",

View File

@@ -59,3 +59,26 @@ class TestEmailCampaign(ERPNextTestSuite):
doc.email_campaign_for = "Lead"
doc.recipient = lead.name
self.assertRaises(frappe.ValidationError, doc.validate_lead)
def test_contact_without_an_email_is_rejected(self):
contact = frappe.get_doc({"doctype": "Contact", "first_name": "_Test Contact No Email"}).insert()
campaign = self.make_campaign(schedules=[0])
doc = self.make_email_campaign(campaign.name)
doc.email_campaign_for = "Contact"
doc.recipient = contact.name
self.assertRaisesRegex(frappe.ValidationError, "primary email ID", doc.insert)
def test_contact_with_an_email_is_accepted(self):
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "_Test Contact With Email",
"email_ids": [{"email_id": "_test_email_campaign@example.com", "is_primary": 1}],
}
).insert()
campaign = self.make_campaign(schedules=[0])
doc = self.make_email_campaign(campaign.name)
doc.email_campaign_for = "Contact"
doc.recipient = contact.name
doc.insert()
self.assertEqual(doc.status, "In Progress")

View File

@@ -236,6 +236,8 @@ class Lead(SellingController, CRMNote):
@frappe.whitelist()
def create_prospect_and_contact(self, data: dict):
self.check_permission("write")
data = frappe._dict(data)
if data.create_contact:
self.create_contact()
@@ -382,6 +384,9 @@ def get_lead_with_phone_number(number):
@frappe.whitelist(methods=["POST"])
def add_lead_to_prospect(lead: str, prospect: str):
if lead:
frappe.has_permission("Lead", "read", lead, throw=True)
prospect = frappe.get_doc("Prospect", prospect)
prospect.append("leads", {"lead": lead})
prospect.save()

View File

@@ -133,30 +133,6 @@
"link_type": "Sidebar",
"title": "Quality"
},
{
"added": 0,
"hidden": 0,
"icon": "messages-square",
"link_to": "Communication",
"link_type": "Sidebar",
"title": "Communication"
},
{
"added": 0,
"hidden": 0,
"icon": "phone",
"link_to": "Telephony",
"link_type": "Sidebar",
"title": "Telephony"
},
{
"added": 0,
"hidden": 0,
"icon": "layers",
"link_to": "Bulk Transaction",
"link_type": "Sidebar",
"title": "Bulk Transaction"
},
{
"added": 0,
"hidden": 0,
@@ -164,17 +140,9 @@
"link_to": "Subcontracting",
"link_type": "Sidebar",
"title": "Subcontracting"
},
{
"added": 0,
"hidden": 0,
"icon": "file-code",
"link_to": "EDI",
"link_type": "Sidebar",
"title": "EDI"
}
],
"modified": "2026-08-28 12:00:00.000000",
"modified": "2026-09-02 14:58:17.082794",
"modified_by": "Administrator",
"name": "erpnext",
"owner": "Administrator",

View File

@@ -1,6 +1,7 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import re
from typing import TYPE_CHECKING
import frappe
@@ -78,8 +79,48 @@ class CodeList(Document):
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)
def _version_key(version: str | None) -> list:
"""Natural sort key for the version formats publishers use: integers and ISO dates.
Orders 3 < 10 (which a lexical sort gets wrong) and 2020-01-01 < 2020-11-05.
"""
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", version or "")]
@frappe.request_cache
def resolve_code_list(code_list: str) -> str | None:
"""Return the Code List for a document name or a canonical URI.
Code Lists are named after their CanonicalVersionUri, so one canonical URI can
map to several documents, one per version. An exact document name takes
precedence, which lets a caller request a specific version; a canonical URI
resolves to the latest version available.
"""
if frappe.db.exists("Code List", code_list):
return code_list
candidates = frappe.get_all(
"Code List",
filters={"canonical_uri": code_list},
fields=["name", "version"],
)
if not candidates:
return None
# ponytail: assumes one publisher sticks to one version format. An integer and an
# ISO date under the same canonical URI compare numerically (3 < 2020), so the date
# would win; import the genericode ValidityDate and sort on that if it ever happens.
return max(candidates, key=lambda cl: _version_key(cl.version)).name
def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
"""Return the common code for a given record"""
"""Return the common code for a given record.
`code_list` may be a Code List name or a canonical URI (latest version wins).
"""
if not (code_list := resolve_code_list(code_list)):
return ()
CommonCode = frappe.qb.DocType("Common Code")
DynamicLink = frappe.qb.DocType("Dynamic Link")
@@ -101,7 +142,13 @@ def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
"""Return the record name for a given common code"""
"""Return the record name for a given common code.
`code_list` may be a Code List name or a canonical URI (latest version wins).
"""
if not (code_list := resolve_code_list(code_list)):
return ()
CommonCode = frappe.qb.DocType("Common Code")
DynamicLink = frappe.qb.DocType("Dynamic Link")
@@ -123,6 +170,12 @@ def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
def get_default_code(code_list: str) -> str | None:
"""Return the default common code for a given code list"""
"""Return the default common code for a given code list.
`code_list` may be a Code List name or a canonical URI (latest version wins).
"""
if not (code_list := resolve_code_list(code_list)):
return None
code_id = frappe.db.get_value("Code List", code_list, "default_common_code")
return frappe.db.get_value("Common Code", code_id, "common_code") if code_id else None

View File

@@ -1,9 +1,83 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.edi.doctype.code_list.code_list import (
_version_key,
get_codes_for,
get_default_code,
get_docnames_for,
resolve_code_list,
)
from erpnext.tests.utils import ERPNextTestSuite
CANONICAL_URI = "urn:test:erpnext:codeliste:resolve"
OLD_VERSION = f"{CANONICAL_URI}:3"
NEW_VERSION = f"{CANONICAL_URI}:10"
UNKNOWN_URI = "urn:test:erpnext:codeliste:missing"
class TestCodeList(ERPNextTestSuite):
pass
def setUp(self):
"""Create two versions of one code list. Test records are rolled back per test."""
for name, version in ((OLD_VERSION, "3"), (NEW_VERSION, "10")):
if not frappe.db.exists("Code List", name):
frappe.get_doc(
doctype="Code List",
name=name,
title=name,
canonical_uri=CANONICAL_URI,
version=version,
).insert()
default_code = frappe.get_doc(
doctype="Common Code",
title="Test Default",
common_code="XYZ",
code_list=NEW_VERSION,
).insert()
frappe.db.set_value("Code List", NEW_VERSION, "default_common_code", default_code.name)
# resolution is request-cached, so fixtures must not be masked by earlier lookups
frappe.local.request_cache.clear()
def test_version_key_orders_integers_and_iso_dates(self):
"""Integer and ISO date versions must both order correctly, unlike a lexical sort."""
self.assertEqual(sorted(["10", "3", None, "9"], key=_version_key), [None, "3", "9", "10"])
self.assertEqual(
sorted(["2020-11-05", "2019-12-31", "2020-01-01"], key=_version_key),
["2019-12-31", "2020-01-01", "2020-11-05"],
)
def test_canonical_uri_resolves_to_latest_version(self):
self.assertEqual(resolve_code_list(CANONICAL_URI), NEW_VERSION)
def test_name_resolves_to_itself(self):
"""Passing a version-specific name must return that version, not the latest one."""
self.assertEqual(resolve_code_list(OLD_VERSION), OLD_VERSION)
def test_name_takes_precedence_over_canonical_uri(self):
"""A document named like a canonical URI must not redirect to another version."""
frappe.get_doc(
doctype="Code List",
name=CANONICAL_URI,
title=CANONICAL_URI,
canonical_uri=CANONICAL_URI,
version="1",
).insert()
frappe.local.request_cache.clear()
self.assertEqual(resolve_code_list(CANONICAL_URI), CANONICAL_URI)
def test_unknown_uri_resolves_to_none(self):
self.assertIsNone(resolve_code_list(UNKNOWN_URI))
def test_lookups_are_empty_for_unknown_code_list(self):
"""An unresolved code list must not fall through to an unfiltered query."""
self.assertEqual(get_codes_for(UNKNOWN_URI, "UOM", "Nos"), ())
self.assertEqual(get_docnames_for(UNKNOWN_URI, "UOM", "XYZ"), ())
self.assertIsNone(get_default_code(UNKNOWN_URI))
def test_default_code_follows_latest_version(self):
self.assertEqual(get_default_code(CANONICAL_URI), "XYZ")

View File

@@ -1,48 +0,0 @@
{
"app": "erpnext",
"creation": "2026-08-16 00:00:00.000000",
"docstatus": 0,
"doctype": "Sidebar",
"header_icon": "file-code",
"idx": 0,
"items": [
{
"added": 0,
"child": 0,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Code List",
"link_to": "Code List",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 0,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Common Code",
"link_to": "Common Code",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-08-16 00:00:00.000000",
"modified_by": "Administrator",
"module": "EDI",
"name": "EDI",
"owner": "Administrator",
"sequence_id": 0.0,
"standard": 1,
"title": "EDI"
}

View File

@@ -21,9 +21,101 @@
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 0,
"collapsible": 1,
"hidden": 0,
"icon": "phone",
"indent": 1,
"is_default_module": 0,
"keep_closed": 0,
"label": "Telephony",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"icon": "settings",
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Incoming Call Settings",
"link_to": "Incoming Call Settings",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"icon": "settings",
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Voice Call Settings",
"link_to": "Voice Call Settings",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Call Log",
"link_to": "Call Log",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Telephony Call Type",
"link_to": "Telephony Call Type",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Communication Medium",
"link_to": "Communication Medium",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-08-16 00:00:00.000000",
"modified": "2026-09-02 14:58:17.082794",
"modified_by": "Administrator",
"module": "ERPNext Integrations",
"name": "ERPNext Integrations",

View File

@@ -21,6 +21,23 @@ add_to_apps_screen = [
}
]
# Modules that are a folder of code and nothing else. Their doctypes, reports and controllers stay
# where they are; what they no longer own is navigation, which now sits in the sidebar named beside
# each. Left in the dock, each would carry an entry of its own for two to four records. See
# `frappe.utils.modules.get_code_only_modules`.
#
# The value names the modules that inherited that navigation, so a Call Log or a Code List resolves
# to a sidebar the user can actually navigate to instead of dead-ending in a module the dock never
# shows.
code_only_modules = {
"Telephony": ["ERPNext Integrations"],
# Its one doctype, Communication Medium, describes how a call reaches someone, so it sits in
# the Telephony section beside the call settings rather than in a shell of its own.
"Communication": ["ERPNext Integrations"],
"EDI": ["Utilities"],
"Bulk Transaction": ["Utilities"],
}
develop_version = "17.x.x-develop"
app_include_js = "erpnext.bundle.js"
@@ -70,8 +87,6 @@ after_install = "erpnext.setup.install.after_install"
after_app_install = "erpnext.setup.install.after_app_install"
after_app_uninstall = "erpnext.setup.install.after_app_uninstall"
before_tests = "erpnext.tests.utils.bootstrap_test_data"
boot_session = "erpnext.startup.boot.boot_session"
notification_config = "erpnext.startup.notifications.get_notification_config"
get_help_messages = "erpnext.utilities.activation.get_help_messages"

View File

@@ -1,6 +1,6 @@
{
"app": "erpnext",
"creation": "2026-08-16 00:00:00.000000",
"creation": "2026-08-16 00:00:00",
"docstatus": 0,
"doctype": "Sidebar",
"header_icon": "wrench",
@@ -41,7 +41,7 @@
"child": 0,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"indent": 1,
"is_default_module": 0,
"keep_closed": 0,
"label": "Reports",
@@ -67,12 +67,11 @@
"type": "Link"
}
],
"modified": "2026-08-16 00:00:00.000000",
"modified": "2026-09-01 19:30:16.713938",
"modified_by": "Administrator",
"module": "Maintenance",
"name": "Maintenance",
"owner": "Administrator",
"sequence_id": 0.0,
"standard": 1,
"title": "Maintenance"
}

View File

@@ -4,6 +4,10 @@
frappe.ui.form.on("Blanket Order", {
onload: function (frm) {
frm.trigger("set_tc_name_filter");
if (frm.is_new()) {
let has_pricing = frm.doc.currency || frm.doc.selling_price_list || frm.doc.buying_price_list;
blanket_order_pricing.apply(frm, null, { reset_party_values: !has_pricing });
}
},
setup: function (frm) {
@@ -15,10 +19,13 @@ frappe.ui.form.on("Blanket Order", {
frm.add_fetch("customer", "customer_name", "customer_name");
frm.add_fetch("supplier", "supplier_name", "supplier_name");
frm.set_query("selling_price_list", () => ({ filters: { selling: 1 } }));
frm.set_query("buying_price_list", () => ({ filters: { buying: 1 } }));
},
refresh: function (frm) {
erpnext.hide_company(frm);
blanket_order_pricing.update_labels(frm);
if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) {
frm.add_custom_button(
__("Sales Order"),
@@ -101,5 +108,141 @@ frappe.ui.form.on("Blanket Order", {
blanket_order_type: function (frm) {
frm.trigger("set_tc_name_filter");
return reset_party_pricing(frm);
},
company: reset_party_pricing,
customer: reset_party_pricing,
supplier: reset_party_pricing,
currency: function (frm) {
return blanket_order_pricing.apply(frm, null, { reset_conversion_rate: true });
},
from_date: function (frm) {
return blanket_order_pricing.apply(frm, null, {
reset_conversion_rate: true,
reset_plc_conversion_rate: true,
});
},
conversion_rate: async function (frm) {
await blanket_order_pricing.update_base_rates(frm);
return blanket_order_pricing.apply(frm);
},
selling_price_list: reset_price_list_exchange_rate,
buying_price_list: reset_price_list_exchange_rate,
plc_conversion_rate: function (frm) {
return blanket_order_pricing.apply(frm);
},
});
frappe.ui.form.on("Blanket Order Item", {
item_code: apply_item_pricing,
qty: apply_item_pricing,
rate: function (frm, cdt, cdn) {
return set_base_rate(frm, frappe.get_doc(cdt, cdn));
},
});
const blanket_order_pricing = {
update_base_rates(frm) {
return Promise.all((frm.doc.items || []).map((item) => set_base_rate(frm, item)));
},
update_labels(frm) {
let company_currency = this.get_company_currency(frm);
let show_base_rate = Boolean(
frm.doc.currency && company_currency && frm.doc.currency !== company_currency
);
frm.set_currency_labels(["price_list_rate", "rate"], frm.doc.currency || company_currency, "items");
frm.set_currency_labels(["base_price_list_rate", "base_rate"], company_currency, "items");
frm.fields_dict.items.grid.set_column_disp("base_price_list_rate", show_base_rate);
frm.fields_dict.items.grid.set_column_disp("base_rate", show_base_rate);
frm.toggle_display("conversion_rate", show_base_rate);
frm.toggle_display(
"plc_conversion_rate",
Boolean(frm.doc.price_list_currency && frm.doc.price_list_currency !== company_currency)
);
frm.set_df_property(
"conversion_rate",
"description",
show_base_rate ? `1 ${frm.doc.currency} = [?] ${company_currency}` : ""
);
frm.refresh_fields();
},
get_company_currency(frm) {
return frm.doc.company ? erpnext.get_currency(frm.doc.company) : null;
},
async apply(frm, item_name = null, options = {}) {
if (!frm.doc.company || !frm.doc.blanket_order_type) {
return;
}
if (frm.__applying_blanket_order_price_list) {
frm.__pending_blanket_order_price_list = { item_name, options };
return;
}
frm.__applying_blanket_order_price_list = true;
let pending;
try {
let response = await frappe.call({
method: "erpnext.manufacturing.doctype.blanket_order.blanket_order.apply_price_list",
args: {
doc: frm.doc,
item_name,
reset_party_values: options.reset_party_values,
reset_conversion_rate: options.reset_conversion_rate,
reset_plc_conversion_rate: options.reset_plc_conversion_rate,
},
});
if (response.message) {
await frm.set_value(response.message.parent);
for (const values of response.message.children) {
let { name, ...fields } = values;
let item = (frm.doc.items || []).find((row) => row.name === name);
if (item) {
await frappe.model.set_value(item.doctype, item.name, fields);
}
}
this.update_labels(frm);
}
} finally {
frm.__applying_blanket_order_price_list = false;
pending = frm.__pending_blanket_order_price_list;
frm.__pending_blanket_order_price_list = null;
}
if (pending) {
return this.apply(frm, pending.item_name, pending.options);
}
},
};
function reset_party_pricing(frm) {
return blanket_order_pricing.apply(frm, null, { reset_party_values: true });
}
function reset_price_list_exchange_rate(frm) {
return blanket_order_pricing.apply(frm, null, { reset_plc_conversion_rate: true });
}
function apply_item_pricing(frm, cdt, cdn) {
return blanket_order_pricing.apply(frm, cdn);
}
function set_base_rate(frm, item) {
frappe.model.round_floats_in(item, ["rate"]);
let base_rate = flt(flt(item.rate) * flt(frm.doc.conversion_rate), precision("base_rate", item));
return frappe.model.set_value(item.doctype, item.name, "base_rate", base_rate);
}

View File

@@ -18,6 +18,14 @@
"from_date",
"to_date",
"company",
"currency_and_price_list",
"currency",
"conversion_rate",
"column_break_price_list",
"selling_price_list",
"buying_price_list",
"price_list_currency",
"plc_conversion_rate",
"section_break_12",
"items",
"amended_from",
@@ -96,6 +104,66 @@
"reqd": 1,
"search_index": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"label": "Currency and Price List"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency",
"print_hide": 1,
"reqd": 1
},
{
"fieldname": "column_break_price_list",
"fieldtype": "Column Break"
},
{
"description": "Rate at which document currency is converted to company currency",
"fieldname": "conversion_rate",
"fieldtype": "Float",
"label": "Exchange Rate",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
{
"depends_on": "eval:doc.blanket_order_type == \"Selling\"",
"fieldname": "selling_price_list",
"fieldtype": "Link",
"label": "Price List",
"options": "Price List",
"print_hide": 1
},
{
"depends_on": "eval:doc.blanket_order_type == \"Purchasing\"",
"fieldname": "buying_price_list",
"fieldtype": "Link",
"label": "Price List",
"options": "Price List",
"print_hide": 1
},
{
"fieldname": "price_list_currency",
"fieldtype": "Link",
"label": "Price List Currency",
"options": "Currency",
"print_hide": 1,
"read_only": 1
},
{
"description": "Rate at which Price List Currency is converted to Company Currency",
"fieldname": "plc_conversion_rate",
"fieldtype": "Float",
"label": "Price List Exchange Rate",
"precision": "9",
"print_hide": 1
},
{
"fieldname": "section_break_12",
"fieldtype": "Section Break"
@@ -147,7 +215,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-08-21 23:11:40.122402",
"modified": "2026-08-27 10:55:37.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Blanket Order",

View File

@@ -9,6 +9,9 @@ from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import flt, getdate
from erpnext import get_company_currency
from erpnext.accounts.services.taxes import validate_conversion_rate
from erpnext.manufacturing.doctype.blanket_order import blanket_order_pricing
from erpnext.stock.doctype.item.item import get_item_defaults
@@ -25,7 +28,10 @@ class BlanketOrder(Document):
amended_from: DF.Link | None
blanket_order_type: DF.Literal["", "Selling", "Purchasing"]
buying_price_list: DF.Link | None
company: DF.Link
conversion_rate: DF.Float
currency: DF.Link
customer: DF.Link | None
customer_name: DF.Data | None
from_date: DF.Date
@@ -33,6 +39,9 @@ class BlanketOrder(Document):
naming_series: DF.Literal["MFG-BLR-.YYYY.-"]
order_date: DF.Date | None
order_no: DF.Data | None
plc_conversion_rate: DF.Float
price_list_currency: DF.Link | None
selling_price_list: DF.Link | None
supplier: DF.Link | None
supplier_name: DF.Data | None
tc_name: DF.Link | None
@@ -40,11 +49,42 @@ class BlanketOrder(Document):
to_date: DF.Date
# end: auto-generated types
def before_validate(self):
self.set_currency()
self.set_conversion_rate()
blanket_order_pricing.set_price_list(self)
def validate(self):
self.validate_dates()
self.validate_duplicate_items()
self.validate_item_qty()
self.set_party_item_code()
self.set_base_rates()
def set_currency(self):
if self.currency:
return
config = blanket_order_pricing.get_order_type_config(self.blanket_order_type)
party_type = config["party_type"]
party = self.get(config["party_field"])
party_currency = frappe.get_cached_value(party_type, party, "default_currency") if party else None
self.currency = party_currency or get_company_currency(self.company)
def set_conversion_rate(self):
company_currency = get_company_currency(self.company)
if self.currency == company_currency:
self.conversion_rate = 1.0
elif not self.conversion_rate:
self.conversion_rate = blanket_order_pricing.get_exchange_rate_to_company(self, self.currency)
validate_conversion_rate(
self.currency,
self.conversion_rate,
self.meta.get_translated_label("conversion_rate"),
self.company,
)
self.conversion_rate = flt(self.conversion_rate, self.precision("conversion_rate"))
def validate_dates(self):
if getdate(self.from_date) > getdate(self.to_date):
@@ -123,6 +163,26 @@ class BlanketOrder(Document):
if flt(d.qty) <= 0:
frappe.throw(_("Row {0}: Quantity must be greater than zero.").format(d.idx))
def set_base_rates(self):
blanket_order_pricing.set_base_rates(self)
@frappe.whitelist()
def apply_price_list(
doc: str | dict,
item_name: str | None = None,
reset_party_values: bool = False,
reset_plc_conversion_rate: bool = False,
reset_conversion_rate: bool = False,
):
return blanket_order_pricing.apply_price_list(
doc=doc,
item_name=item_name,
reset_party_values=reset_party_values,
reset_plc_conversion_rate=reset_plc_conversion_rate,
reset_conversion_rate=reset_conversion_rate,
)
@frappe.whitelist()
def make_order(source_name: str):
@@ -136,14 +196,12 @@ def make_order(source_name: str):
def update_item(source, target, source_parent):
target_qty = source.get("qty") - source.get("ordered_qty")
target.qty = target_qty if flt(target_qty) >= 0 else 0
target.rate = source.get("rate")
item = get_item_defaults(target.item_code, source_parent.company)
if item:
target.item_name = item.get("item_name")
target.description = item.get("description")
target.uom = item.get("stock_uom")
target.against_blanket_order = 1
target.blanket_order = source_name
target_doc = get_mapped_doc(
"Blanket Order",
@@ -156,7 +214,10 @@ def make_order(source_name: str):
},
"Blanket Order Item": {
"doctype": doctype + " Item",
"field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"},
"field_map": {
"rate": "blanket_order_rate",
"parent": "blanket_order",
},
"postprocess": update_item,
"condition": lambda item: not (flt(item.qty)) or (flt(item.qty) - flt(item.ordered_qty)) > 0,
},

View File

@@ -0,0 +1,228 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.utils import cint, flt
from erpnext import get_company_currency
from erpnext.accounts.party import get_default_price_list as get_party_default_price_list
from erpnext.accounts.services.taxes import validate_conversion_rate
from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.get_item_details import get_price_list_rate_for
_ORDER_TYPE_CONFIG = {
"Selling": {
"exchange_rate_type": "for_selling",
"opposite_price_list_field": "buying_price_list",
"party_field": "customer",
"party_type": "Customer",
"price_list_field": "selling_price_list",
"price_list_type": "Selling",
"settings_doctype": "Selling Settings",
},
"Purchasing": {
"exchange_rate_type": "for_buying",
"opposite_price_list_field": "selling_price_list",
"party_field": "supplier",
"party_type": "Supplier",
"price_list_field": "buying_price_list",
"price_list_type": "Buying",
"settings_doctype": "Buying Settings",
},
}
def get_order_type_config(blanket_order_type):
return _ORDER_TYPE_CONFIG[blanket_order_type]
def get_exchange_rate_to_company(doc, currency):
config = get_order_type_config(doc.blanket_order_type)
return get_exchange_rate(
currency,
get_company_currency(doc.company),
doc.from_date,
config["exchange_rate_type"],
)
def set_price_list(doc, set_default=False, force_exchange_rate=False):
config = get_order_type_config(doc.blanket_order_type)
fieldname = config["price_list_field"]
doc.set(config["opposite_price_list_field"], None)
if not doc.get(fieldname) and (doc.is_new() or set_default):
doc.set(fieldname, get_default_price_list(doc))
price_list = doc.get(fieldname)
if not price_list:
clear_price_list(doc)
return
price_list_type = config["price_list_type"].lower()
price_list_details = frappe.get_cached_value(
"Price List", price_list, ["currency", price_list_type, "enabled"], as_dict=True
)
if not price_list_details or not price_list_details.enabled:
frappe.throw(_("Price List {0} is disabled or does not exist").format(frappe.bold(price_list)))
if not price_list_details.get(price_list_type):
frappe.throw(
_("Price List {0} is not enabled for {1}").format(
frappe.bold(price_list), frappe.bold(doc.blanket_order_type)
)
)
price_list_currency_changed = doc.price_list_currency != price_list_details.currency
doc.price_list_currency = price_list_details.currency
company_currency = get_company_currency(doc.company)
if doc.price_list_currency == company_currency:
doc.plc_conversion_rate = 1.0
elif price_list_currency_changed or not doc.plc_conversion_rate or force_exchange_rate:
doc.plc_conversion_rate = get_exchange_rate_to_company(doc, doc.price_list_currency)
validate_conversion_rate(
doc.price_list_currency,
doc.plc_conversion_rate,
doc.meta.get_translated_label("plc_conversion_rate"),
doc.company,
)
doc.plc_conversion_rate = flt(doc.plc_conversion_rate, doc.precision("plc_conversion_rate"))
def clear_price_list(doc):
doc.price_list_currency = None
doc.plc_conversion_rate = 0
for item in doc.items:
item.price_list_rate = 0
item.base_price_list_rate = 0
def get_default_price_list(doc):
config = get_order_type_config(doc.blanket_order_type)
party_type = config["party_type"]
party = doc.get(config["party_field"])
if party:
party_price_list = get_party_default_price_list(frappe.get_cached_doc(party_type, party))
if party_price_list:
return party_price_list
return frappe.db.get_single_value(config["settings_doctype"], config["price_list_field"])
def get_price_list_rates(doc, item_name=None):
price_list = doc.get(get_order_type_config(doc.blanket_order_type)["price_list_field"])
items = [item for item in doc.items if item.item_code and (not item_name or item.name == item_name)]
if not items:
return []
if not price_list:
return [{"name": item.name, "price_list_rate": 0, "base_price_list_rate": 0} for item in items]
stock_uoms = dict(
frappe.get_all(
"Item",
filters={"name": ("in", [item.item_code for item in items])},
fields=["name", "stock_uom"],
as_list=True,
)
)
ctx = frappe._dict(
{
"price_list": price_list,
"customer": doc.customer,
"supplier": doc.supplier,
"transaction_date": doc.from_date,
}
)
rates = []
for item in items:
stock_uom = stock_uoms.get(item.item_code)
ctx.update(
{
"qty": flt(item.qty) or 1,
"uom": stock_uom,
"stock_uom": stock_uom,
"conversion_factor": 1,
}
)
price_list_rate = get_price_list_rate_for(ctx, item.item_code)
rate_details = {"name": item.name, "price_list_rate": 0, "base_price_list_rate": 0}
if price_list_rate is not None:
rate = flt(price_list_rate) * flt(doc.plc_conversion_rate) / flt(doc.conversion_rate)
price_list_rate, base_price_list_rate = get_rate_and_base_amount(
doc, item, "price_list_rate", rate
)
rate, base_rate = get_rate_and_base_amount(doc, item, "rate", rate)
rate_details.update(
{
"price_list_rate": price_list_rate,
"base_price_list_rate": base_price_list_rate,
"rate": rate,
"base_rate": base_rate,
}
)
rates.append(rate_details)
return rates
def set_base_rates(doc):
for item in doc.items:
for fieldname in ("price_list_rate", "rate"):
rate, base_rate = get_rate_and_base_amount(doc, item, fieldname, item.get(fieldname))
item.set(fieldname, rate)
item.set(f"base_{fieldname}", base_rate)
def get_rate_and_base_amount(doc, item, fieldname, rate):
rate = flt(rate, item.precision(fieldname))
base_fieldname = f"base_{fieldname}"
base_rate = flt(rate * flt(doc.conversion_rate), item.precision(base_fieldname))
return rate, base_rate
def apply_price_list(
doc,
item_name=None,
reset_party_values=False,
reset_plc_conversion_rate=False,
reset_conversion_rate=False,
):
doc = frappe.get_doc(frappe.parse_json(doc))
reset_party_values = cint(reset_party_values)
reset_plc_conversion_rate = cint(reset_plc_conversion_rate)
reset_conversion_rate = cint(reset_conversion_rate)
if reset_party_values:
doc.currency = None
doc.conversion_rate = 0
doc.selling_price_list = None
doc.buying_price_list = None
doc.price_list_currency = None
doc.plc_conversion_rate = 0
else:
if reset_conversion_rate:
doc.conversion_rate = 0
if reset_plc_conversion_rate:
doc.plc_conversion_rate = 0
doc.set_currency()
doc.set_conversion_rate()
set_price_list(
doc,
set_default=reset_party_values,
force_exchange_rate=reset_party_values or reset_plc_conversion_rate,
)
return {
"parent": {
"currency": doc.currency,
"conversion_rate": doc.conversion_rate,
"selling_price_list": doc.selling_price_list,
"buying_price_list": doc.buying_price_list,
"price_list_currency": doc.price_list_currency,
"plc_conversion_rate": doc.plc_conversion_rate,
},
"children": get_price_list_rates(doc, item_name),
}

View File

@@ -1,13 +1,18 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import frappe
from frappe.utils import add_months, today
from frappe.utils import add_months, flt, today
from erpnext import get_company_currency
from erpnext.controllers.queries import get_blanket_orders
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.get_item_details import get_blanket_order_details
from erpnext.tests.utils import ERPNextTestSuite
from .blanket_order import make_order
from . import blanket_order_pricing
from .blanket_order import apply_price_list, make_order
class TestBlanketOrder(ERPNextTestSuite):
@@ -184,21 +189,241 @@ class TestBlanketOrder(ERPNextTestSuite):
with self.assertRaises(frappe.ValidationError):
bo.insert()
def test_multicurrency_blanket_order(self):
company_currency = get_company_currency("_Test Company")
transaction_currency = "USD" if company_currency != "USD" else "EUR"
conversion_rate = 80
rate = 5
for blanket_order_type, target_doctypes in (
("Selling", ("Sales Order", "Quotation")),
("Purchasing", ("Purchase Order",)),
):
blanket_order = make_blanket_order(
blanket_order_type=blanket_order_type,
currency=transaction_currency,
conversion_rate=conversion_rate,
rate=rate,
)
self.assertEqual(blanket_order.currency, transaction_currency)
self.assertEqual(blanket_order.conversion_rate, conversion_rate)
self.assertEqual(blanket_order.items[0].base_rate, rate * conversion_rate)
for target_doctype in target_doctypes:
with self.subTest(target_doctype=target_doctype):
frappe.flags.args.doctype = target_doctype
target = make_order(blanket_order.name)
self.assertEqual(target.currency, transaction_currency)
self.assertEqual(target.conversion_rate, conversion_rate)
self.assertEqual(target.items[0].rate, rate)
self.assertEqual(target.items[0].base_rate, rate * conversion_rate)
self.assertEqual(target.items[0].blanket_order_rate, rate)
self.assertEqual(target.items[0].blanket_order, blanket_order.name)
def test_price_list_rates_and_mapping(self):
company = "_Test Company"
company_currency = get_company_currency(company)
transaction_currency = "USD" if company_currency != "USD" else "EUR"
conversion_rate = 80
price_list_rate = 800
for blanket_order_type, price_list_field, target_doctypes in (
("Selling", "selling_price_list", ("Sales Order", "Quotation")),
("Purchasing", "buying_price_list", ("Purchase Order",)),
):
blanket_order, price_list = make_priced_blanket_order(
blanket_order_type=blanket_order_type,
company=company,
currency=transaction_currency,
conversion_rate=conversion_rate,
price_list_rate=price_list_rate,
qty=1000,
)
blanket_order.insert()
blanket_order.submit()
expected_rate = price_list_rate / conversion_rate
self.assertEqual(blanket_order.price_list_currency, company_currency)
self.assertEqual(blanket_order.plc_conversion_rate, 1)
self.assertEqual(blanket_order.items[0].price_list_rate, expected_rate)
self.assertEqual(blanket_order.items[0].base_price_list_rate, price_list_rate)
self.assertEqual(blanket_order.items[0].rate, expected_rate)
self.assertEqual(blanket_order.items[0].base_rate, price_list_rate)
for target_doctype in target_doctypes:
with self.subTest(target_doctype=target_doctype):
frappe.flags.args.doctype = target_doctype
target = make_order(blanket_order.name)
self.assertEqual(target.get(price_list_field), price_list)
self.assertEqual(target.price_list_currency, company_currency)
self.assertEqual(target.plc_conversion_rate, 1)
self.assertEqual(target.items[0].price_list_rate, expected_rate)
self.assertEqual(target.items[0].base_price_list_rate, price_list_rate)
self.assertEqual(target.items[0].rate, expected_rate)
self.assertEqual(target.items[0].blanket_order, blanket_order.name)
def test_applying_price_list_ignores_empty_item_rows(self):
blanket_order = frappe.new_doc("Blanket Order")
blanket_order.blanket_order_type = "Selling"
blanket_order.company = "_Test Company"
blanket_order.customer = "_Test Customer"
blanket_order.from_date = today()
blanket_order.append("items", {})
pricing = apply_price_list(blanket_order.as_dict())
self.assertEqual(pricing["children"], [])
def test_price_list_rate_is_fetched_on_item_selection(self):
company = "_Test Company"
company_currency = get_company_currency(company)
price_list_rate = 800
blanket_order, _price_list = make_priced_blanket_order(
company=company,
currency=company_currency,
conversion_rate=1,
price_list_rate=price_list_rate,
qty=0,
)
item = blanket_order.items[0]
self.assertEqual(item.price_list_rate, price_list_rate)
self.assertEqual(item.rate, price_list_rate)
def test_price_list_rates_fetch_item_uoms_once(self):
blanket_order = new_blanket_order("Selling")
blanket_order.selling_price_list = "_Test Price List"
for item_code in ("ITEM-1", "ITEM-2"):
blanket_order.append("items", {"item_code": item_code, "qty": 1})
with (
patch.object(
blanket_order_pricing.frappe,
"get_all",
return_value=[["ITEM-1", "Nos"], ["ITEM-2", "Nos"]],
) as get_all,
patch.object(blanket_order_pricing, "get_price_list_rate_for", return_value=None),
):
rates = blanket_order_pricing.get_price_list_rates(blanket_order)
self.assertEqual(len(rates), 2)
get_all.assert_called_once_with(
"Item",
filters={"name": ("in", ["ITEM-1", "ITEM-2"])},
fields=["name", "stock_uom"],
as_list=True,
)
def test_price_list_conversion_uses_currency_precision(self):
company = "_Test Company"
company_currency = get_company_currency(company)
transaction_currency = "USD" if company_currency != "USD" else "EUR"
conversion_rate = 95.47
price_list_rate = 100
blanket_order, _price_list = make_priced_blanket_order(
company=company,
currency=transaction_currency,
conversion_rate=conversion_rate,
price_list_rate=price_list_rate,
)
item = blanket_order.items[0]
expected_rate = flt(price_list_rate / conversion_rate, item.precision("rate"))
expected_base_rate = flt(expected_rate * conversion_rate, item.precision("base_rate"))
self.assertFalse(frappe.get_meta("Blanket Order Item").get_field("rate").precision)
self.assertEqual(item.price_list_rate, expected_rate)
self.assertEqual(item.base_price_list_rate, expected_base_rate)
self.assertEqual(item.rate, expected_rate)
self.assertEqual(item.base_rate, expected_base_rate)
blanket_order.insert()
blanket_order.submit()
frappe.flags.args.doctype = "Sales Order"
sales_order = make_order(blanket_order.name)
sales_order.delivery_date = today()
sales_order.insert()
self.assertEqual(sales_order.items[0].price_list_rate, item.price_list_rate)
self.assertEqual(sales_order.items[0].base_price_list_rate, item.base_price_list_rate)
self.assertEqual(sales_order.items[0].rate, item.rate)
self.assertEqual(sales_order.items[0].base_rate, item.base_rate)
def test_applying_price_list_can_reset_conversion_rate(self):
company_currency = get_company_currency("_Test Company")
transaction_currency = "USD" if company_currency != "USD" else "EUR"
blanket_order, _price_list = make_priced_blanket_order(
currency=transaction_currency,
conversion_rate=80,
price_list_rate=100,
)
with patch(
"erpnext.manufacturing.doctype.blanket_order.blanket_order_pricing.get_exchange_rate",
return_value=95.47,
):
pricing = apply_price_list(blanket_order.as_dict(), reset_conversion_rate=True)
self.assertEqual(pricing["parent"]["conversion_rate"], 95.47)
expected_rate = flt(
100 / pricing["parent"]["conversion_rate"],
blanket_order.items[0].precision("rate"),
)
expected_base_rate = flt(
expected_rate * pricing["parent"]["conversion_rate"],
blanket_order.items[0].precision("base_rate"),
)
self.assertEqual(pricing["children"][0]["base_rate"], expected_base_rate)
def test_blanket_order_lookup_filters_currency(self):
company_currency = get_company_currency("_Test Company")
transaction_currency = "USD" if company_currency != "USD" else "EUR"
blanket_order = make_blanket_order(
blanket_order_type="Selling",
currency=transaction_currency,
conversion_rate=80,
)
filters = {
"company": blanket_order.company,
"currency": transaction_currency,
"blanket_order_type": "Selling",
"item": blanket_order.items[0].item_code,
}
matching_orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
self.assertIn(blanket_order.name, [order[0] for order in matching_orders])
filters["currency"] = company_currency
other_currency_orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
self.assertNotIn(blanket_order.name, [order[0] for order in other_currency_orders])
details = get_blanket_order_details(
{
"blanket_order": blanket_order.name,
"company": blanket_order.company,
"currency": company_currency,
"customer": blanket_order.customer,
"doctype": "Sales Order",
"item_code": blanket_order.items[0].item_code,
"transaction_date": today(),
}
)
self.assertFalse(details)
def make_blanket_order(**args):
args = frappe._dict(args)
bo = frappe.new_doc("Blanket Order")
bo.blanket_order_type = args.blanket_order_type
bo.company = args.company or "_Test Company"
if args.blanket_order_type == "Selling":
bo.customer = args.customer or "_Test Customer"
else:
bo.supplier = args.supplier or "_Test Supplier"
bo.from_date = today()
bo.to_date = add_months(bo.from_date, months=12)
bo = new_blanket_order(
blanket_order_type=args.blanket_order_type,
company=args.company or "_Test Company",
currency=args.currency,
conversion_rate=args.conversion_rate or 1,
customer=args.customer,
supplier=args.supplier,
)
bo.append(
"items",
{
@@ -211,3 +436,84 @@ def make_blanket_order(**args):
bo.insert()
bo.submit()
return bo
def make_priced_blanket_order(
blanket_order_type="Selling",
company="_Test Company",
currency=None,
conversion_rate=1,
price_list_rate=800,
qty=1,
):
price_list = make_blanket_order_price_list(get_company_currency(company), price_list_rate)
blanket_order = new_blanket_order(
blanket_order_type=blanket_order_type,
company=company,
currency=currency,
conversion_rate=conversion_rate,
)
config = blanket_order_pricing.get_order_type_config(blanket_order_type)
blanket_order.set(config["price_list_field"], price_list)
item = blanket_order.append("items", {"item_code": "_Test Item", "qty": qty, "rate": 0})
pricing = apply_price_list(blanket_order.as_dict())
blanket_order.update(pricing["parent"])
item.update({key: value for key, value in pricing["children"][0].items() if key != "name"})
return blanket_order, price_list
def new_blanket_order(
blanket_order_type,
company="_Test Company",
currency=None,
conversion_rate=1,
customer=None,
supplier=None,
):
blanket_order = frappe.new_doc("Blanket Order")
blanket_order.blanket_order_type = blanket_order_type
blanket_order.company = company
blanket_order.currency = currency or get_company_currency(company)
blanket_order.conversion_rate = conversion_rate
blanket_order.from_date = today()
blanket_order.to_date = add_months(blanket_order.from_date, months=12)
config = blanket_order_pricing.get_order_type_config(blanket_order_type)
party = customer if config["party_field"] == "customer" else supplier
blanket_order.set(config["party_field"], party or f"_Test {config['party_type']}")
return blanket_order
def make_blanket_order_price_list(currency, price_list_rate):
price_list = "_Test Blanket Order Price List"
if not frappe.db.exists("Price List", price_list):
frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": price_list,
"currency": currency,
"selling": 1,
"buying": 1,
}
).insert()
else:
frappe.db.set_value("Price List", price_list, {"currency": currency, "selling": 1, "buying": 1})
item_price = frappe.db.get_value(
"Item Price", {"price_list": price_list, "item_code": "_Test Item"}, "name"
)
if item_price:
frappe.db.set_value("Item Price", item_price, "price_list_rate", price_list_rate)
else:
frappe.get_doc(
{
"doctype": "Item Price",
"price_list": price_list,
"item_code": "_Test Item",
"price_list_rate": price_list_rate,
}
).insert()
return price_list

View File

@@ -10,7 +10,10 @@
"party_item_code",
"column_break_3",
"qty",
"price_list_rate",
"base_price_list_rate",
"rate",
"base_rate",
"ordered_qty",
"section_break_7",
"terms_and_conditions"
@@ -41,11 +44,37 @@
"in_list_view": 1,
"label": "Quantity"
},
{
"fieldname": "price_list_rate",
"fieldtype": "Currency",
"label": "Price List Rate",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "base_price_list_rate",
"fieldtype": "Currency",
"label": "Price List Rate (Company Currency)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate",
"options": "currency",
"reqd": 1
},
{
"fieldname": "base_rate",
"fieldtype": "Currency",
"label": "Rate (Company Currency)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1,
"reqd": 1
},
{
@@ -74,7 +103,7 @@
],
"istable": 1,
"links": [],
"modified": "2024-03-27 13:06:40.083042",
"modified": "2026-08-27 10:55:37.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Blanket Order Item",
@@ -85,4 +114,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -14,6 +14,8 @@ class BlanketOrderItem(Document):
if TYPE_CHECKING:
from frappe.types import DF
base_price_list_rate: DF.Currency
base_rate: DF.Currency
item_code: DF.Link
item_name: DF.Data | None
ordered_qty: DF.Float
@@ -21,6 +23,7 @@ class BlanketOrderItem(Document):
parentfield: DF.Data
parenttype: DF.Data
party_item_code: DF.Data | None
price_list_rate: DF.Currency
qty: DF.Float
rate: DF.Currency
terms_and_conditions: DF.Text | None

View File

@@ -699,7 +699,9 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
get_bom_material_detail(doc, cdt, cdn, secondary_items);
}
buying_price_list(doc) {
buying_price_list() {
const doc = this.frm.doc;
if (doc.rm_cost_as_per !== "Price List" && doc.buying_price_list) {
this.frm.set_value("buying_price_list", "");
return;
@@ -710,8 +712,8 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
}
}
plc_conversion_rate(doc) {
if (!this.in_apply_price_list && doc.rm_cost_as_per === "Price List") {
plc_conversion_rate() {
if (!this.in_apply_price_list && this.frm.doc.rm_cost_as_per === "Price List") {
this.apply_price_list(null, true);
}
}
@@ -767,6 +769,7 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
conversion_factor: d.conversion_factor,
sourced_by_supplier: d.sourced_by_supplier,
do_not_explode: d.do_not_explode,
source_warehouse: d.source_warehouse || doc.default_source_warehouse,
fetch_rate: !secondary_items,
},
callback: function (r) {
@@ -777,6 +780,10 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
doc = locals[doc.doctype][doc.name];
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
if (secondary_items && d.valuation_type === "Valuation Rate") {
erpnext.bom.fetch_secondary_item_cost(doc, cdt, cdn);
}
},
freeze: true,
});
@@ -790,11 +797,10 @@ cur_frm.cscript.qty = function (doc) {
cur_frm.cscript.rate = function (doc, cdt, cdn) {
var d = locals[cdt][cdn];
const is_secondary_item = cdt == "BOM Secondary Item";
if (d.bom_no) {
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
get_bom_material_detail(doc, cdt, cdn, is_secondary_item);
get_bom_material_detail(doc, cdt, cdn, false);
} else {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
@@ -957,6 +963,9 @@ frappe.ui.form.on("BOM Item", {
do_not_explode: function (frm, cdt, cdn) {
get_bom_material_detail(frm.doc, cdt, cdn, false);
},
source_warehouse: function (frm, cdt, cdn) {
get_bom_material_detail(frm.doc, cdt, cdn, false);
},
});
frappe.ui.form.on("BOM Item", "qty", function (frm, cdt, cdn) {
@@ -1029,11 +1038,48 @@ frappe.tour["BOM"] = [
];
frappe.ui.form.on("BOM Secondary Item", {
item_code(frm, cdt, cdn) {
const { item_code } = locals[cdt][cdn];
valuation_type(frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (row.valuation_type !== "% of FG Cost") {
frappe.model.set_value(cdt, cdn, "cost_allocation_per", 0);
}
if (row.valuation_type === "Valuation Rate") {
erpnext.bom.fetch_secondary_item_cost(frm.doc, cdt, cdn);
} else if (row.valuation_type !== "Manual") {
frappe.model.set_value(cdt, cdn, { cost: 0, base_cost: 0 });
}
},
});
erpnext.bom.fetch_secondary_item_cost = function (doc, cdt, cdn) {
const row = locals[cdt][cdn];
if (!row.item_code) return;
frappe.call({
doc: doc,
method: "get_bom_material_detail",
args: {
company: doc.company,
item_code: row.item_code,
uom: row.uom,
stock_uom: row.stock_uom,
conversion_factor: row.conversion_factor,
warehouse: doc.default_target_warehouse,
set_rate_based_on_warehouse: 1,
force_valuation_rate: 1,
fetch_rate: 1,
bom_no: "",
},
callback(r) {
const cost = flt(r.message.rate) * flt(row.stock_qty);
frappe.model.set_value(cdt, cdn, {
cost: cost,
base_cost: cost * flt(doc.conversion_rate || 1),
});
},
});
};
function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) {
frappe.prompt(
{

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