Compare commits

...

81 Commits

Author SHA1 Message Date
Frappe PR Bot
8378b6e203 chore(release): Bumped to Version 16.30.0
# [16.30.0](https://github.com/frappe/erpnext/compare/v16.29.0...v16.30.0) (2026-07-28)

### Bug Fixes

* add permission check for `get_item_details` (backport [#57515](https://github.com/frappe/erpnext/issues/57515)) ([#57551](https://github.com/frappe/erpnext/issues/57551)) ([7a606ab](7a606ab91c))
* bump item doctype modified timestamp so description change syncs on migrate ([2471c5c](2471c5ccbb))
* **crm:** align Opportunity status checks with Quotation statuses (backport [#57489](https://github.com/frappe/erpnext/issues/57489)) ([#57491](https://github.com/frappe/erpnext/issues/57491)) ([ad9870a](ad9870acc1))
* **crm:** clarify the reason why an opportunity cannot be declared as lost (backport [#57495](https://github.com/frappe/erpnext/issues/57495)) ([#57498](https://github.com/frappe/erpnext/issues/57498)) ([3ea19d8](3ea19d8eb1))
* detect the currency column by fieldtype in trends total row ([a30aac8](a30aac87bf))
* enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (backport [#57458](https://github.com/frappe/erpnext/issues/57458)) ([#57460](https://github.com/frappe/erpnext/issues/57460)) ([a0af717](a0af717234))
* enhance growth view filtering by validating period keys ([3f87836](3f87836536))
* force-delete repost data file during cleanup (backport [#57245](https://github.com/frappe/erpnext/issues/57245)) ([32baf6a](32baf6a47c))
* get reserved batch qty precision from settings ([95de237](95de2374ed))
* guard against missing is_your_company_address custom field on address ([2e6b4b5](2e6b4b5838))
* Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime  (backport [#57380](https://github.com/frappe/erpnext/issues/57380)) ([#57397](https://github.com/frappe/erpnext/issues/57397)) ([84813d7](84813d7f46))
* **item:** correct description on deferred revenue/expense ([ee12f8d](ee12f8d2d4))
* **manufacturing:** exclude corrective job cards from semi-FG aggregate ([ebc8482](ebc8482310))
* **manufacturing:** fall back to UOM Conversion Factor in Production Plan (backport [#57553](https://github.com/frappe/erpnext/issues/57553)) ([#57554](https://github.com/frappe/erpnext/issues/57554)) ([8f36753](8f36753705))
* **manufacturing:** scope BOM Creator tree children to the parent row ([339bb0b](339bb0b4ea)), closes [#57311](https://github.com/frappe/erpnext/issues/57311)
* **manufacturing:** sum semi-FG qty across split job cards ([c411b8e](c411b8e471))
* **manufacturing:** update cost of BOMs created via BOM Creator ([f1a0a5e](f1a0a5e1bf))
* map MT940 per-transaction reference from :61: customer_reference ([b9c4f79](b9c4f790bd))
* map pick list customer to delivery note when no sales order ([3f6501b](3f6501b4ff)), closes [#57412](https://github.com/frappe/erpnext/issues/57412)
* migrate stored AR/AP ageing filter to renamed field ([2e5fc38](2e5fc38179))
* pool batch slot values on every run, not only when negative ([5763378](5763378ee1))
* **quotation:** carry forward communications from opportunity (backport [#57507](https://github.com/frappe/erpnext/issues/57507)) ([#57509](https://github.com/frappe/erpnext/issues/57509)) ([f9b3e42](f9b3e42dcd))
* rebalance batch slot values at the pooled rate when driven negative ([8a50572](8a50572786))
* recalculate operating cost on hour rate change in routing ([c182b40](c182b4085b))
* recover failed POS closings (backport [#57203](https://github.com/frappe/erpnext/issues/57203)) ([#57572](https://github.com/frappe/erpnext/issues/57572)) ([9c946eb](9c946eb168))
* rename misleading filter labels in AR/AP reports ([6d856fa](6d856fa632))
* resolve backport conflicts in item doctype ([0ce5040](0ce50407f9))
* respect selected BOM when creating work order for variant item ([#57359](https://github.com/frappe/erpnext/issues/57359)) ([1132eb1](1132eb1a0f))
* respect user permissions in party dashboard company list ([c79f2e4](c79f2e45a8)), closes [frappe/erpnext#57428](https://github.com/frappe/erpnext/issues/57428)
* **setup:** scope manufacturing warehouse filters to company ([f05e8ed](f05e8ed0ce))
* skip stock expense GL entries for non-stock items ([#57518](https://github.com/frappe/erpnext/issues/57518)) ([861c50e](861c50e727))
* **stock:** narrow legacy serial ledger lookup by item (backport [#57499](https://github.com/frappe/erpnext/issues/57499)) ([#57506](https://github.com/frappe/erpnext/issues/57506)) ([59efe72](59efe7299a))
* stop storing raw title template on subcontracting orders ([0863c1e](0863c1e05c))
* **subcontracting:** release raw-material reservation when closing a subcontracting order ([ee75fac](ee75fac9d7))
* **test:** flaky test in exchange rate revaluation ([484ff8e](484ff8e349))
* typeerror in get_batches_by_oldest for mixed batch expiry ([bbe7580](bbe7580c9d))
* update formatting of growth view for FS report ([da3844c](da3844c4df))
* update operating cost when propagating workstation hour rate to routing ([#57504](https://github.com/frappe/erpnext/issues/57504)) ([39d5fd8](39d5fd84db))
* use company currency instead of global default in report (backport [#56561](https://github.com/frappe/erpnext/issues/56561)) ([9e5f77b](9e5f77b57c))

### Features

* block sales invoice submit when customer overdue exceeds threshold (backport [#57230](https://github.com/frappe/erpnext/issues/57230), [#57298](https://github.com/frappe/erpnext/issues/57298)) ([#57438](https://github.com/frappe/erpnext/issues/57438)) ([d3c5e86](d3c5e866f0)), closes [#52960](https://github.com/frappe/erpnext/issues/52960)
* book Expenses Added To Stock GL entries (backport [#57190](https://github.com/frappe/erpnext/issues/57190) + [#57475](https://github.com/frappe/erpnext/issues/57475)) ([#57503](https://github.com/frappe/erpnext/issues/57503)) ([68caa60](68caa60dfa))
* make Shipping Rule Cost Center optional with company default fallback (backport [#57355](https://github.com/frappe/erpnext/issues/57355)) ([#57385](https://github.com/frappe/erpnext/issues/57385)) ([bbd942c](bbd942c600))
2026-07-28 23:31:26 +00:00
Diptanil Saha
eaf95e5c36 Merge pull request #57548 from frappe/version-16-hotfix
chore: release v16
2026-07-29 04:59:39 +05:30
Shllokkk
52f61c088e Merge pull request #57568 from frappe/mergify/bp/version-16-hotfix/pr-57566
fix(item): correct description on deferred revenue/expense (backport #57566)
2026-07-28 23:21:22 +05:30
mergify[bot]
9c946eb168 fix: recover failed POS closings (backport #57203) (#57572)
Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com>
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-07-28 22:58:24 +05:30
Mihir Kandoi
8f36753705 fix(manufacturing): fall back to UOM Conversion Factor in Production Plan (backport #57553) (#57554)
fix(manufacturing): fall back to UOM Conversion Factor in Production Plan

Production Plan read the conversion factor straight off the item's own
UOM child table, so an item with a purchase UOM but no matching row threw
"UOM Conversion factor not found" while Stock Entry silently resolved it
from the item's variant template or the UOM Conversion Factor doctype.
Resolve it the same way, and keep returning None when nothing is
configured anywhere so the missing-setup error still fires.
2026-07-28 15:02:05 +00:00
mergify[bot]
7a606ab91c fix: add permission check for get_item_details (backport #57515) (#57551)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-28 14:47:51 +00:00
Shllokkk
2471c5ccbb fix: bump item doctype modified timestamp so description change syncs on migrate 2026-07-28 19:40:10 +05:30
Shllokkk
0ce50407f9 fix: resolve backport conflicts in item doctype 2026-07-28 19:33:33 +05:30
Shllokkk
ee12f8d2d4 fix(item): correct description on deferred revenue/expense
(cherry picked from commit fa75aa08ab)

# Conflicts:
#	erpnext/stock/doctype/item/item.json
#	erpnext/stock/doctype/item/item.py
2026-07-28 13:52:59 +00:00
Khushi Rawat
c690ed0058 Merge pull request #57429 from frappe/mergify/bp/version-16-hotfix/pr-57382
fix: map MT940 per-transaction reference from :61: customer_reference (backport #57382)
2026-07-28 18:00:14 +05:30
Mihir Kandoi
083ef9baa7 Merge pull request #57542 from frappe/mergify/bp/version-16-hotfix/pr-57540
fix(setup): scope manufacturing warehouse filters to company (backport #57540)
2026-07-28 14:22:17 +05:30
Mihir Kandoi
f05e8ed0ce fix(setup): scope manufacturing warehouse filters to company
Default WIP, Finished Goods and Scrap Warehouse fields on Company listed
warehouses of every company. Filter them by the current company and
exclude group warehouses, matching the other warehouse fields.

(cherry picked from commit 632113c309)

# Conflicts:
#	erpnext/setup/doctype/company/company.js
2026-07-28 14:20:05 +05:30
Mihir Kandoi
feec193d7d Merge pull request #57536 from mihir-kandoi/backport-bom-creator-update-cost-v16
fix(manufacturing): update cost of BOMs created via BOM Creator (backport #57532)
2026-07-28 13:06:27 +05:30
rohitwaghchaure
861c50e727 fix: skip stock expense GL entries for non-stock items (#57518)
* fix: skip stock expense gl entries for non stock items

(cherry picked from commit 747f4df778dca45cf044c02f0e933d3b230b8334)

* test: use a leaf expense account for the service item invoice
2026-07-28 13:00:41 +05:30
Mihir Kandoi
f1a0a5e1bf fix(manufacturing): update cost of BOMs created via BOM Creator
`calculate_rm_cost` skipped rate refresh whenever `bom_creator` was set,
so neither the Update Cost button nor the BOM Update Tool could ever
refresh those BOMs. Every BOM in a multi-level tree carries the field, so
whole trees stayed frozen at their creation rates.

The guard replaced the removed `rm_cost_as_per == "Manual"` check in
0b63dbf, on the assumption that BOM Creator rows hold manual rates. They
do not: BOM Creator recomputes every row from `rm_cost_as_per` on save.
2026-07-28 12:49:21 +05:30
Mihir Kandoi
7eb0eb77aa Merge pull request #57529 from frappe/mergify/bp/version-16-hotfix/pr-57521
fix(manufacturing): sum semi-FG qty across split job cards (backport #57521)
2026-07-28 12:47:43 +05:30
Mihir Kandoi
ca15a14666 test: import make_job_card from work_order module on v16 2026-07-28 12:34:50 +05:30
Mihir Kandoi
afbe1f7e53 Merge pull request #57530 from frappe/mergify/bp/version-16-hotfix/pr-57528
fix(manufacturing): scope BOM Creator tree children to the parent row (backport #57528)
2026-07-28 12:30:24 +05:30
Mihir Kandoi
339bb0b4ea fix(manufacturing): scope BOM Creator tree children to the parent row
The BOM Creator tree identified a node by the parent's item code
(fg_item) instead of the specific BOM Creator Item row, so every
occurrence of a repeated sub-assembly shared one child set: expanding
any one of them listed the raw materials of all of them, and deleting
one wiped the raw materials of its siblings.

Key the tree on fg_reference_id and make the node value the row name,
matching the framework convention that a tree node's value is its
docname. Item code now travels as its own field for the label and for
the fg_item argument sent back on add/convert.

Fixes #57311

(cherry picked from commit b37152752f)
2026-07-28 06:49:59 +00:00
Diptanil Saha
fe6534b888 refactor(postgres): port point_of_sale get_items to the query builder (partial backport #56153) (#57527)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 12:17:20 +05:30
Mihir Kandoi
ebc8482310 fix(manufacturing): exclude corrective job cards from semi-FG aggregate
(cherry picked from commit bde118e7cf)
2026-07-28 06:44:37 +00:00
Mihir Kandoi
c411b8e471 fix(manufacturing): sum semi-FG qty across split job cards
update_semi_finished_good_details assigned the current job card's
manufactured_qty to Work Order.produced_qty instead of accumulating it,
so a second job card on the same operation overwrote the first. Nothing
corrected it afterwards because StatusService.update_work_order_qty
returns early for track_semi_finished_goods work orders, leaving the
work order stuck below its planned qty with no way to progress.

Aggregate manufactured_qty and completed_qty over the operation's
submitted job cards instead.

(cherry picked from commit 5548f0726a)
2026-07-28 06:44:37 +00:00
Mihir Kandoi
06ba783267 Merge pull request #57523 from aerele/backport-56561-version-16-hotfix
fix: use company currency instead of global default in report (backpo…
2026-07-28 12:13:00 +05:30
pandiyan
a30aac87bf fix: detect the currency column by fieldtype in trends total row
calculate_total_row tested each column with `"Link/Currency" in col`, but
based-on and group-by columns are dicts, so the test checked the dict's keys
and never matched. currency_col_idx stayed None and the grand-total row's
currency cell was left unset, so Total(Amt) rendered with the global default
currency instead of the company's.

Match the dict's fieldtype/options instead. Dict columns are never numeric
and string columns are never Link columns, so the two branches are now
mutually exclusive.
2026-07-28 11:28:07 +05:30
Mihir Kandoi
c7ffe822f1 Merge pull request #57517 from frappe/mergify/bp/version-16-hotfix/pr-57493
fix: stop storing raw title template on subcontracting orders (backport #57493)
2026-07-28 11:09:03 +05:30
pandiyan
9e5f77b57c fix: use company currency instead of global default in report (backport #56561)
Reports like Sales Order Trends and Purchase Order Trends showed the global
default currency symbol instead of the transacting company's currency.

Threads the company currency through conditions["company_currency"] in
trends.get_columns and uses it for both the chart's currency and the Total
row. The chart now skips the grand-total row by its label instead of by a
falsy first periodic cell, so the already-summed Total row is not added into
the datapoints a second time.

Backport of #56561 (frappe/erpnext). Tests from the original PR are not
included: the trends report test files do not exist on this branch.
2026-07-28 11:08:13 +05:30
pandiyan
0863c1e05c fix: stop storing raw title template on subcontracting orders
subcontracting order and subcontracting inward order carry a hidden
title field defaulting to "{supplier_name}" / "{customer_name}", while
their title_field points at supplier_name / customer_name. document.
set_title_field() substitutes the template only when title_field is
"title", so every record stores the placeholder verbatim.

drop the dead default and hidden flags, move title into the other info
tab to match purchase order, and add a patch to repair existing rows.

(cherry picked from commit 5008e6126f)
2026-07-28 05:12:49 +00:00
Shllokkk
39d5fd84db fix: update operating cost when propagating workstation hour rate to routing (#57504) 2026-07-28 10:26:47 +05:30
rohitwaghchaure
68caa60dfa feat: book Expenses Added To Stock GL entries (backport #57190 + #57475) (#57503)
* fix: exclude landed cost from purchase expense GL entries

* feat: book expenses added to stock GL entries for stock vouchers

* test: enable stock expense gl entries flag for purchase expense test
2026-07-27 18:32:13 +00:00
mergify[bot]
f9b3e42dcd fix(quotation): carry forward communications from opportunity (backport #57507) (#57509)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-27 18:14:11 +00:00
mergify[bot]
59efe7299a fix(stock): narrow legacy serial ledger lookup by item (backport #57499) (#57506)
fix(stock): narrow legacy serial ledger lookup by item (#57499)

Filter legacy Stock Ledger Entry lookups by item code so the existing
item and warehouse index can reduce rows scanned during serial valuation.

(cherry picked from commit 425191e57e)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-27 22:10:00 +05:30
mergify[bot]
3ea19d8eb1 fix(crm): clarify the reason why an opportunity cannot be declared as lost (backport #57495) (#57498)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-27 17:01:21 +02:00
ruthra kumar
1f9c4bc933 Merge pull request #57484 from ruthra-kumar/fix_flaky_err_test
fix(test): flaky test in exchange rate revaluation
2026-07-27 17:09:32 +05:30
ruthra kumar
484ff8e349 fix(test): flaky test in exchange rate revaluation
- remove redundant setup on system settings
2026-07-27 16:57:09 +05:30
mergify[bot]
ad9870acc1 fix(crm): align Opportunity status checks with Quotation statuses (backport #57489) (#57491)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-27 12:22:35 +02:00
Mihir Kandoi
8925585895 Merge pull request #57481 from frappe/mergify/bp/version-16-hotfix/pr-57463
fix(subcontracting): release raw-material reservation when closing a subcontracting order (backport #57463)
2026-07-27 15:01:03 +05:30
Mihir Kandoi
8d511c9b71 Merge pull request #57487 from frappe/mergify/bp/version-16-hotfix/pr-57485
fix: pool batch slot values on every run, not only when negative (backport #57485)
2026-07-27 14:04:17 +05:30
Mihir Kandoi
85925680ac test: assert batch pooling preserves the group total on a repeating rate
(cherry picked from commit 545262c5d4)
2026-07-27 08:24:15 +00:00
Mihir Kandoi
5763378ee1 fix: pool batch slot values on every run, not only when negative
A batch is one valuation pool, so any per-slot value difference within a
batch is stale detail from the report's own age slots, not real valuation.
The rebalance only ran when consumption had already driven a slot negative,
so a batch whose receipts landed at different rates kept a skewed split
across age buckets (one bucket free, another double-priced) while the total
stayed correct.

Drop the negative-slot precondition and always spread a batch's pooled value
over its slots in proportion to qty. Redistribution preserves group totals,
so buckets still sum to Stock Balance; only the split across ages changes.

(cherry picked from commit cedaaa3a00)
2026-07-27 08:24:15 +00:00
Lakshit Jain
c3eac77ee1 Merge pull request #56952 from frappe/mergify/bp/version-16-hotfix/pr-54855
refactor(financial-report): fix row transformation for growth calculations (backport #54855)
2026-07-27 13:31:55 +05:30
Sudharsanan11
008c3b145d test(subcontracting): cover reservation release on closing a subcontracting order
close a partially-received sco with a reserve warehouse and assert the
raw-material reservation is released and projected qty recovers.

(cherry picked from commit e4b8065a69)
2026-07-27 13:01:24 +05:30
Sudharsanan11
ee75fac9d7 fix(subcontracting): release raw-material reservation when closing a subcontracting order
the bin reserved-qty recalc filtered out closed purchase orders but not
closed subcontracting orders, so closing a partially-received sco kept the
reservation for the unreceived qty and left projected qty understated.
apply the same closed-status filter to the subcontracting order path.

(cherry picked from commit db91a79d31)

# Conflicts:
#	erpnext/stock/doctype/bin/bin.py
2026-07-27 13:01:24 +05:30
ruthra kumar
220d005cc2 Merge pull request #57478 from frappe/mergify/bp/version-16-hotfix/pr-57476
refactor: configurable date in reverse ERR journals (backport #57476)
2026-07-27 12:34:16 +05:30
ruthra kumar
ed1a58aa2a ci: debugging 2026-07-27 12:23:33 +05:30
ruthra kumar
2fd1dbd23a refactor(test): manually submit reverse err journal
(cherry picked from commit 1a558ce641)

# Conflicts:
#	erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py
2026-07-27 11:53:29 +05:30
ruthra kumar
68dcc96527 refactor: configurable date in reverse ERR journals
(cherry picked from commit 0be33e4132)
2026-07-27 06:11:10 +00:00
Shllokkk
708d889036 Merge pull request #57473 from frappe/mergify/bp/version-16-hotfix/pr-57443
fix: rename misleading filter labels in AR/AP reports (backport #57443)
2026-07-26 20:22:08 +05:30
Shllokkk
20e2728037 chore: resolve patches.txt conflict for backport 2026-07-26 19:21:01 +05:30
MochaMind
2359e1610a chore: update POT file (#57470) 2026-07-26 15:45:55 +02:00
Shllokkk
2e5fc38179 fix: migrate stored AR/AP ageing filter to renamed field
(cherry picked from commit f13cd00494)

# Conflicts:
#	erpnext/patches.txt
2026-07-26 11:47:26 +00:00
Shllokkk
6d856fa632 fix: rename misleading filter labels in AR/AP reports
(cherry picked from commit e99425b7c4)
2026-07-26 11:47:26 +00:00
Shllokkk
1802c75ed6 Merge pull request #57468 from frappe/mergify/bp/version-16-hotfix/pr-57466
fix: recalculate operating cost on hour rate change in routing (backport #57466)
2026-07-26 16:27:11 +05:30
Shllokkk
c182b4085b fix: recalculate operating cost on hour rate change in routing
(cherry picked from commit 598f6f0f4e)
2026-07-26 08:37:06 +00:00
mergify[bot]
a0af717234 fix: enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (backport #57458) (#57460)
fix: enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (#57458)

(cherry picked from commit 4e8f5de5cb)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-25 08:27:06 +05:30
Jatin3128
d3c5e866f0 feat: block sales invoice submit when customer overdue exceeds threshold (backport #57230, #57298) (#57438)
* feat: block sales invoice submit when customer overdue exceeds threshold (#57230)

* feat: block sales invoice submit when customer overdue exceeds threshold

Adds an opt-in, per-customer Overdue Billing Threshold. When enabled in
Accounts Settings, submitting a Sales Invoice is blocked if the customer's
overdue amount exceeds their threshold, unless the current user holds a
configured bypass role. Modeled on the existing credit limit feature.

- Accounts Settings (Credit Limits tab): enable toggle + bypass role.
- Per-customer threshold on the Customer Credit Limit table, shown only
  when the feature is enabled via a property setter (same mechanism as
  subscription / accounting dimension sections). Table relabeled to
  "Credit & Overdue Limits".
- Overdue is read live from the ledger via get_outstanding_invoices
  (payments already netted), summing Sales Invoices past their due date.
- Enforced in Sales Invoice on_submit, after the credit-limit check;
  returns are exempt.
- validate_credit_limit_on_change no longer trips when a row sets only
  the overdue threshold (credit_limit = 0).

Fixes #52960

* fix: compute overdue amount in company currency and format with fmt_money

get_customer_overdue_amount now sums GL Entry debit - credit grouped per
invoice, which is always booked in company currency, instead of using
get_outstanding_invoices which returns the receivable-account currency.
The threshold is in company currency, so the previous comparison could mix
currencies for customers with a foreign-currency receivable account. This
mirrors how get_customer_outstanding computes the figure for the existing
credit-limit check.

The blocking message now formats both amounts with fmt_money using the
company currency.

Adds a test asserting a 100 USD invoice at a conversion rate of 50 is
counted as 5000 in company currency.

* refactor: drop redundant threshold coercion and dead test cleanup

- Coerce the overdue threshold with flt() once when reading it, instead of
  calling flt() on it at each of the three use sites.
- Remove a no-op set_overdue_billing_threshold() call in the feature-disabled
  block (the threshold was already set to that value) and the trailing reset,
  which is dead since each test is rolled back.

No behaviour change.

* fix: compute overdue amount from payment terms, matching the Overdue status

The overdue amount keyed on Sales Invoice.due_date, which set_due_date() sets
to the LAST payment term. An invoice whose first term was past due and unpaid
was therefore counted as zero, even though ERPNext already shows it as Overdue
in the invoice list. The gate and the UI could disagree.

get_customer_overdue_amount now follows the same rule as is_overdue(): per
invoice, the amount that has fallen due (sum of payment schedule terms past
their due date) minus what has been paid, clamped to the outstanding balance.
Invoices without a schedule (POS, opening) still fall back to the invoice due
date, mirroring is_overdue()'s own guard.

The ledger stays the source of truth for what is unpaid: the outstanding per
invoice is still SUM(debit) - SUM(credit) from GL Entry. base_payment_amount is
always stored in company currency, so no currency conversion is needed and the
comparison against the threshold stays consistent.

Adds a test covering a two-term invoice: only the past-due term counts, and
paying it off clears the overdue amount.

* feat: honour the overdue billing threshold set on the customer group

The threshold lives on Customer Credit Limit, which is also rendered on
Customer Group. A threshold set there was stored but never evaluated, so the
configuration was a silent no-op.

get_overdue_billing_threshold now reads the customer's row and falls back to
its customer group, mirroring get_credit_limit. The group's
bypass_credit_limit_check is deliberately not consulted: it is labelled for the
credit limit check at sales order and is unrelated to overdue billing.

get_customer_group_details also dropped the threshold when copying group rows
onto a customer, because it copied a single hardcoded field per table. It now
copies a list of fields per table, so credit_limit and overdue_billing_threshold
both carry over.

* refactor: clearer labels for the overdue billing control (#57298)

refactor: clearer labels and messages, drop "threshold" wording

User-facing text only, no field or behaviour changes:

- Accounts Settings toggle label -> "Restrict Customer Over Billing".
- Bypass role label -> "Role Allowed to Bypass Over Billing Restriction".
- Customer Credit Limit field label -> "Overdue Limit".
- Rewrote the descriptions and the block message to match and to stop
  saying "threshold".

* fix: treat zero overdue limit as opt-out and isolate settings in test

get_overdue_billing_threshold treated an explicit 0 on the customer's credit
limit row as "not set" and fell back to the customer group. A customer could
not be exempted from the group restriction while keeping a credit limit row,
and every existing row defaults to 0, so enabling the feature on a group blocked
all its customers that had any credit limit row. Guard the group fallback on
"threshold is None" (no row for the company) instead of a falsy check, so an
explicit 0 acts as an opt-out.

test_overdue_billing_threshold_on_submit mutated the Accounts Settings singleton
without restoring it, so a failed assertion mid-test leaked
enable_overdue_billing_threshold and the bypass role into later tests that submit
sales invoices. Wrap the mutations in try/finally and restore the originals.

* fix: let a zero customer overdue limit inherit the group's limit

A 0 on the customer's credit limit row falls back to the customer group again;
only a non-zero value on the customer overrides the group. Reverts the earlier
opt-out interpretation and updates the fallback test to expect the group's limit.
2026-07-24 15:33:26 +05:30
Mihir Kandoi
2b8b6a09dd Merge pull request #57440 from frappe/mergify/bp/version-16-hotfix/pr-57435
fix(accounts): respect user permissions in party dashboard company list (backport #57435)
2026-07-24 15:12:35 +05:30
pandiyan
c79f2e45a8 fix: respect user permissions in party dashboard company list
use frappe.get_list instead of frappe.get_all in get_dashboard_info so
the company list honors user permissions. previously, a party with
invoices across multiple companies would raise "User don't have
permissions to select/read this account" for users restricted to a
subset of companies, since get_party_account was called for companies
the user could not access.

fixes frappe/erpnext#57428

(cherry picked from commit 903c87bcaa)
2026-07-24 09:30:41 +00:00
Mihir Kandoi
ab4b5abb91 Merge pull request #57420 from aerele/backport-57412-customer-pick-list-v16
fix: map pick list customer to delivery note when no sales order
2026-07-24 14:58:23 +05:30
pandiyan
3f6501b4ff fix: map pick list customer to delivery note when no sales order
backport of #57412
2026-07-24 11:12:17 +05:30
srujan00123
b9c4f790bd fix: map MT940 per-transaction reference from :61: customer_reference
The mt940 library exposes ``transaction_reference`` from the :20: tag,
which is the statement-level reference and identical for every
transaction in a statement. The bank-statement-to-CSV conversion was
using it verbatim, so every imported row ended up with the same
reference, making reconciliation impossible.

Read the per-transaction reference from ``customer_reference`` on the
:61: tag instead. Handle two edge cases:

- **Overflow >16 chars.** When a bank emits a single-line :61: whose
  reference exceeds 16 characters, the mt940 regex splits the tail into
  ``extra_details``. Gate the rejoin to cases where
  ``customer_reference`` is exactly at the 16-char MT940 cap; below
  that, ``extra_details`` is genuine supplementary information and must
  not be appended.
- **``NONREF`` sentinel.** The MT940 standard marker for "no customer
  reference". Check it against the un-concatenated value so that a
  ``NONREF`` customer reference with populated ``extra_details`` still
  falls back to ``bank_reference`` instead of returning a junk
  ``NONREFsomething`` value.

Also switch the Description column to ``transaction_details`` (the :86:
tag content) so rows carry their real narrative instead of the mostly
empty :61: supplementary field.

(cherry picked from commit 551d709c64)
2026-07-24 05:07:41 +00:00
Mihir Kandoi
2206e9fe2a Merge pull request #57415 from frappe/mergify/bp/version-16-hotfix/pr-57413
fix: typeerror in get_batches_by_oldest for mixed batch expiry (backport #57413)
2026-07-23 18:38:47 +05:30
pandiyan
bbe7580c9d fix: typeerror in get_batches_by_oldest for mixed batch expiry
sort on (expiry is none, expiry) so a null expiry_date is never
order-compared against a datetime.date, which raised typeerror in
python 3 when a warehouse held both dated and never-expiring batches.

(cherry picked from commit 62c9f8ee3e)
2026-07-23 12:53:51 +00:00
Mihir Kandoi
ef21a9ba9b Merge pull request #57250 from frappe/mergify/bp/version-16-hotfix/pr-57245 2026-07-23 16:25:43 +05:30
Mihir Kandoi
88f9039d1a Merge pull request #57408 from frappe/mergify/bp/version-16-hotfix/pr-57400
fix: guard against missing is_your_company_address custom field on ad… (backport #57400)
2026-07-23 16:21:06 +05:30
pandiyan
2e6b4b5838 fix: guard against missing is_your_company_address custom field on address
(cherry picked from commit ea3ed8b836)
2026-07-23 10:41:18 +00:00
Mihir Kandoi
85d793b690 Merge pull request #57404 from mihir-kandoi/fix-stock-ageing-batch-pool-rebalance-v16
fix: rebalance batch slot values at the pooled rate when driven negative (backport #57403)
2026-07-23 16:09:11 +05:30
Mihir Kandoi
8a50572786 fix: rebalance batch slot values at the pooled rate when driven negative
A batch is one valuation pool, so consumption is valued at the pooled
rate while slots may carry stale intra-batch detail (e.g. units
reconciled at zero and later merged). Consuming such a slot leaves a
negative value on positive qty. Spread the pool value across the
batch's slots when that happens; non-batchwise slots pool per
warehouse.
2026-07-23 15:57:13 +05:30
mergify[bot]
bbd942c600 feat: make Shipping Rule Cost Center optional with company default fallback (backport #57355) (#57385)
feat: make Shipping Rule Cost Center optional with company default fallback (#57355)

feat(shipping-rule): make cost center optional with company default fallback

Cost Center on Shipping Rule is no longer mandatory. When left blank, the
applied shipping tax row falls back to the company default cost center,
avoiding the 'Cost Center is required for Profit and Loss account' error on
submit. The rule's project is also applied to the tax row.

(cherry picked from commit a47f25896b)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-23 15:42:28 +05:30
mergify[bot]
84813d7f46 fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime (backport #57380) (#57397)
* fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime  (#57380)

* fix: shift same-timestamp sibling SLEs when cancelling an entry

update_qty_in_future_sle compared against the reversal SLE's own
creation and skipped same-posting_datetime siblings on cancel, leaving
their qty_after_transaction stale and causing false negative stock
errors.

* fix: revert update_qty_in_future_sle cancel tie-break, it double-counted

(cherry picked from commit 8c0ec3c179)

# Conflicts:
#	erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py

* chore: fix conflicts

Fix test case for cancelling stock ledger entries with the same timestamp to ensure correct behavior.

* chore: fix conflicts

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-23 10:08:20 +00:00
Shllokkk
1132eb1a0f fix: respect selected BOM when creating work order for variant item (#57359)
* fix: respect selected BOM when creating work order for variant item

* fix: add type hints to make_work_order
2026-07-23 13:48:45 +05:30
kaulith
2323f2c978 Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-57245 2026-07-23 11:59:44 +05:30
Mihir Kandoi
e277647f7b Merge pull request #57362 from frappe/mergify/bp/version-16-hotfix/pr-57361
refactor: move new-doc route options to StockController (backport #57361)
2026-07-22 18:04:50 +05:30
Mihir Kandoi
c587f4934a refactor: move new-doc route options to StockController
set_route_options_for_new_doc lived in TransactionController, so doctypes
extending StockController directly (Stock Reconciliation, Stock Entry) missed
the Batch/SABB prefill or duplicated it locally. Move it to StockController
and call it from onload_post_render so all descendants inherit it.

- Batch quick entry from Stock Reconciliation items now prefills Item
- SABB route options unified: warehouse || s_warehouse || t_warehouse,
  so transaction doctypes now also prefill warehouse
- Stock Entry's duplicate handler removed; its onload_post_render now
  calls super

(cherry picked from commit 551559e804)
2026-07-22 12:33:46 +00:00
Mihir Kandoi
e0020b478a Merge pull request #57354 from mihir-kandoi/fix/reserved-batch-precision-v16
fix: get reserved batch qty precision from settings (v16)
2026-07-22 15:11:23 +05:30
Mihir Kandoi
95de2374ed fix: get reserved batch qty precision from settings 2026-07-22 14:58:11 +05:30
kaulith
d70e005412 Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-57245 2026-07-19 11:38:25 +05:30
Kaushal Shriwas
32baf6a47c fix: force-delete repost data file during cleanup (backport #57245) 2026-07-17 22:52:30 +05:30
Abdeali Chharchhoda
3f87836536 fix: enhance growth view filtering by validating period keys
(cherry picked from commit aad287d09e)
2026-07-08 10:44:41 +00:00
Abdeali Chharchhoda
da3844c4df fix: update formatting of growth view for FS report
(cherry picked from commit 4c7499600c)
2026-07-08 10:44:40 +00:00
Abdeali Chharchhoda
1f5281d3b8 refactor: simple utility for growth value computation for custom FS report
(cherry picked from commit 698876672d)
2026-07-08 10:44:40 +00:00
Abdeali Chharchhoda
04e7457cea refactor(financial-report): fix row transformation for growth calculations
(cherry picked from commit c179460c98)
2026-07-08 10:44:39 +00:00
91 changed files with 2903 additions and 686 deletions

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.29.0"
__version__ = "16.30.0"
def get_default_company(user=None):

View File

@@ -17,7 +17,7 @@ class ERPNextAddress(Address):
def link_address(self):
"""Link address based on owner"""
if self.is_your_company_address:
if self.get("is_your_company_address"):
return
return super().link_address()
@@ -28,7 +28,9 @@ class ERPNextAddress(Address):
self.is_your_company_address = 1
def validate_reference(self):
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
if self.get("is_your_company_address") and not [
row for row in self.links if row.link_doctype == "Company"
]:
frappe.throw(
_(
"Address needs to be linked to a Company. Please add a row for Company in the Links table."

View File

@@ -21,6 +21,8 @@
"enable_common_party_accounting",
"allow_multi_currency_invoices_against_single_party_account",
"confirm_before_resetting_posting_date",
"stock_expense_section",
"book_stock_expense_gl_entries",
"analytics_section",
"enable_discounts_and_margin",
"enable_accounting_dimensions",
@@ -75,6 +77,8 @@
"over_billing_allowance",
"credit_controller",
"role_allowed_to_over_bill",
"enable_overdue_billing_threshold",
"role_allowed_to_bypass_overdue_billing",
"column_break_11",
"assets_tab",
"asset_settings_section",
@@ -271,6 +275,21 @@
"label": "Role Allowed to over bill ",
"options": "Role"
},
{
"default": "0",
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
"fieldname": "enable_overdue_billing_threshold",
"fieldtype": "Check",
"label": "Restrict Customer Over Billing"
},
{
"depends_on": "eval:doc.enable_overdue_billing_threshold",
"description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.",
"fieldname": "role_allowed_to_bypass_overdue_billing",
"fieldtype": "Link",
"label": "Role Allowed to Bypass Over Billing Restriction",
"options": "Role"
},
{
"fieldname": "period_closing_settings_section",
"fieldtype": "Section Break"
@@ -749,6 +768,18 @@
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
"fieldname": "column_break_mfor",
"fieldtype": "Column Break"
},
{
"fieldname": "stock_expense_section",
"fieldtype": "Section Break",
"label": "Stock Expense Accounting"
},
{
"default": "0",
"description": "Books Purchase Expense and Expenses Added To Stock account pairs against stock value. On enabling this, the accounts become mandatory in Company or Item Defaults for Purchase Receipt, Purchase Invoice, Stock Entry, Stock Reconciliation and Landed Cost Voucher",
"fieldname": "book_stock_expense_gl_entries",
"fieldtype": "Check",
"label": "Book Stock Expense GL Entries"
}
],
"grid_page_length": 50,
@@ -757,7 +788,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-24 12:59:41.868865",
"modified": "2026-07-27 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -62,6 +62,7 @@ class AccountsSettings(Document):
book_asset_depreciation_entry_automatically: DF.Check
book_deferred_entries_based_on: DF.Literal["Days", "Months"]
book_deferred_entries_via_journal_entry: DF.Check
book_stock_expense_gl_entries: DF.Check
book_tax_discount_loss: DF.Check
calculate_depr_using_total_days: DF.Check
check_supplier_invoice_uniqueness: DF.Check
@@ -77,6 +78,7 @@ class AccountsSettings(Document):
enable_fuzzy_matching: DF.Check
enable_immutable_ledger: DF.Check
enable_loyalty_point_program: DF.Check
enable_overdue_billing_threshold: DF.Check
enable_party_matching: DF.Check
enable_subscription: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
@@ -95,6 +97,7 @@ class AccountsSettings(Document):
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
repost_allowed_types: DF.Table[RepostAllowedTypes]
role_allowed_to_bypass_overdue_billing: DF.Link | None
role_allowed_to_over_bill: DF.Link | None
role_to_notify_on_depreciation_failure: DF.Link | None
role_to_override_stop_action: DF.Link | None
@@ -150,6 +153,10 @@ class AccountsSettings(Document):
toggle_subscription_sections(not self.enable_subscription)
clear_cache = True
if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold:
toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold)
clear_cache = True
if clear_cache:
frappe.clear_cache()
@@ -241,6 +248,10 @@ def toggle_subscription_sections(hide):
create_property_setter_for_hiding_field(doctype, "subscription_section", hide)
def toggle_overdue_billing_threshold_field(hide):
create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide)
def create_property_setter_for_hiding_field(doctype, field_name, hide):
make_property_setter(
doctype,

View File

@@ -143,6 +143,30 @@ def preprocess_mt940_content(content: str) -> str:
return processed_content
MT940_CUSTOMER_REFERENCE_MAX_LEN = 16
def get_transaction_reference(txn_data: dict) -> str:
"""Extract the per-transaction reference from an MT940 :61: tag.
The mt940 library exposes ``transaction_reference`` from the :20: tag, which is the
statement-level reference and identical for every transaction in a statement. The
real per-transaction reference is ``customer_reference`` (with any overflow captured
into ``extra_details`` when a bank emits a single-line :61: longer than 16 chars).
"""
customer_reference = (txn_data.get("customer_reference") or "").strip()
if len(customer_reference) == MT940_CUSTOMER_REFERENCE_MAX_LEN:
customer_reference += (txn_data.get("extra_details") or "").strip()
if customer_reference and customer_reference.upper() != "NONREF":
return customer_reference
return (txn_data.get("bank_reference") or "").strip() or (
txn_data.get("transaction_reference") or ""
).strip()
@frappe.whitelist()
def convert_mt940_to_csv(data_import, mt940_file_path):
doc = frappe.get_doc("Bank Statement Import", data_import)
@@ -190,8 +214,8 @@ def convert_mt940_to_csv(data_import, mt940_file_path):
deposit = amount_value if amount_value > 0 else ""
withdrawal = abs(amount_value) if amount_value < 0 else ""
description = txn.data.get("extra_details") or ""
reference = txn.data.get("transaction_reference") or ""
description = txn.data.get("transaction_details") or txn.data.get("extra_details") or ""
reference = get_transaction_reference(txn.data)
currency = txn.data.get("currency", "")
writer.writerow([date_str, deposit, withdrawal, description, reference, doc.bank_account, currency])

View File

@@ -1,7 +1,10 @@
# Copyright (c) 2020, Frappe Technologies and Contributors
# See license.txt
import mt940
from erpnext.accounts.doctype.bank_statement_import.bank_statement_import import (
get_transaction_reference,
is_mt940_format,
preprocess_mt940_content,
)
@@ -188,6 +191,135 @@ class TestBankStatementImport(ERPNextTestSuite):
self.assertIn(":20:STMTREF167619", result) # Reference should remain unchanged
self.assertIn("UPI/TEST USER/123456789/PaidViaTestApp", result)
def test_get_transaction_reference_uses_customer_reference(self):
"""Per-transaction reference must come from :61: customer_reference, not :20:."""
self.assertEqual(
get_transaction_reference(
{"customer_reference": "UPI-100000000001", "transaction_reference": "STMTREF12345"}
),
"UPI-100000000001",
)
def test_get_transaction_reference_rejoins_overflow(self):
"""When a bank emits a single-line :61: with >16-char reference, the regex
splits the tail into extra_details. We must rejoin them."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "NEFTINW-12345678",
"extra_details": "90",
"transaction_reference": "STMTREF12345",
}
),
"NEFTINW-1234567890",
)
def test_get_transaction_reference_falls_back_to_bank_reference_on_nonref(self):
"""NONREF is the MT940 'no customer reference' sentinel; prefer bank_reference."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "NONREF",
"bank_reference": "1234567890123456",
"transaction_reference": "STMTREF12345",
}
),
"1234567890123456",
)
def test_get_transaction_reference_falls_back_to_bank_reference_on_nonref_with_extra_details(self):
"""NONREF sentinel must trigger the bank_reference fallback even when
extra_details is populated. Without the 16-char gate, the old naive concat
would produce a junk reference like 'NONREFsome info' and bypass the check."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "NONREF",
"extra_details": "some info",
"bank_reference": "1234567890123456",
"transaction_reference": "STMTREF12345",
}
),
"1234567890123456",
)
def test_get_transaction_reference_does_not_append_extra_details_below_16_chars(self):
"""When customer_reference is below the 16-char cap, extra_details is a
genuine supplementary-info field from :61: — not overflow — and must not
be appended to the reference."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "TBMS-123456789",
"extra_details": "note field",
"transaction_reference": "STMTREF12345",
}
),
"TBMS-123456789",
)
def test_get_transaction_reference_keeps_noref_literal(self):
"""Bare 'NOREF' (without bank_reference) stays as-is; still better than the
statement-level reference which is identical across all transactions."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "NOREF",
"bank_reference": None,
"transaction_reference": "STMTREF12345",
}
),
"NOREF",
)
def test_mt940_parse_per_transaction_reference_mapping(self):
"""End-to-end: every transaction in a statement must get its own distinct
reference from :61: customer_reference, never the statement-level :20: reference."""
mt940_content = """{1:F0112345678901X0000000000}{2:I94012345678901XN}{4:
:20:STMTREF12345
:25:1234567890
:28C:12345/1
:60F:C250716INR88123,38
:61:2509280928D5000,00NMSCUPI-100000000001
:86:UPI/TEST PAYEE ONE/111111111111/TestApp
:61:2509190919D2606,00NMSCUPI-100000000002
:86:UPI/TEST PAYEE TWO/222222222222/TestApp
:61:2509190919D900,00NMSCUPI-100000000003
:86:UPI/TEST PAYEE THREE/333333333333/TestApp
:61:2508140814D5000,00NMSCUPI-100000000004
:86:UPI/TEST PAYEE FOUR/444444444444/TestApp
:61:2508060806D2000,00NMSCUPI-100000000005
:86:UPI/TEST PAYEE FIVE/555555555555/TestApp
:61:2508030803D1066,00NMSC123456789012
:86:PCD/1234/TEST MERCHANT/01234567890123/12:00
:61:2507310731D305,62NMSCTBMS-123456789
:86:Chrg: Debit Card Annual Fee 1234 for 2025
:61:2507240724C1,00NMSCNEFTINW-1234567890
:86:NEFT TEST123456789 TEST SERVICES
:61:2507170717C100000,00NMSCNOREF
:86:BY CLG INST 123456/01-01-25/TESTBANK/TESTCITY
:62F:C250930INR100000,00
-}"""
transactions = list(mt940.parse(preprocess_mt940_content(mt940_content)))
references = [get_transaction_reference(t.data) for t in transactions]
self.assertEqual(
references,
[
"UPI-100000000001",
"UPI-100000000002",
"UPI-100000000003",
"UPI-100000000004",
"UPI-100000000005",
"123456789012",
"TBMS-123456789",
"NEFTINW-1234567890",
"NOREF",
],
)
# No transaction should carry the statement-level reference from :20:
self.assertNotIn("STMTREF12345", references)
def test_preprocess_mt940_content_whitespace_variants(self):
"""Test handling of whitespace and different line endings"""
# Test with trailing spaces

View File

@@ -616,15 +616,27 @@ class ExchangeRateRevaluation(Document):
if journals:
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.submit()
frappe.msgprint(
_("Revaluation journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
if drafts := frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": ["in", journals]},
pluck="name",
as_list=1,
):
part = "journals are" if len(drafts) > 1 else "journal is"
doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts])
frappe.throw(
msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links),
)
else:
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.save()
frappe.msgprint(
_("A draft reverse journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
)
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):

View File

@@ -9,11 +9,10 @@ from frappe.utils import add_days, flt, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
class TestExchangeRateRevaluation(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company"
self.item = "_Test Item"
@@ -23,14 +22,6 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
self.set_system_and_company_settings()
def set_system_and_company_settings(self):
# set number and currency precision
system_settings = frappe.get_doc("System Settings")
system_settings.float_precision = 2
system_settings.currency_precision = 2
system_settings.language = "en"
system_settings.time_zone = "Asia/Kolkata"
system_settings.save()
# Using Exchange Gain/Loss account for unrealized as well.
company_doc = frappe.get_doc("Company", self.company)
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
@@ -300,3 +291,97 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
for key, _val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
)
def test_05_revaluation_journal_reversal(self):
"""
Test reversing of revaluation journals
"""
si = create_sales_invoice(
item=self.item,
company=self.company,
customer="_Test Customer 1",
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=1,
)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
err = frappe.new_doc("Exchange Rate Revaluation")
err.company = self.company
err.posting_date = today()
err.fetch_and_calculate_accounts_data()
self.assertEqual(len(err.accounts), 1)
err.save().submit()
gain_loss_account = err.get_for_unrealized_gain_loss_account()
usd_account = err.accounts[0].account
old_balance = err.accounts[0].balance_in_base_currency
new_balance = err.accounts[0].new_balance_in_base_currency
total_gain_loss = err.total_gain_loss
# Create JV for ERR
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
je = je.submit()
je.reload()
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
self.assertEqual(len(je.accounts), 3)
# A gain is credited to the gain/loss account, a loss is debited. The current
# exchange rate (from master data) may sit either side of the booked rate, so
# derive the column from the sign instead of assuming a gain.
gain_loss_debit = abs(total_gain_loss) if total_gain_loss < 0 else 0.0
gain_loss_credit = total_gain_loss if total_gain_loss > 0 else 0.0
expected = [
(usd_account, new_balance, 0.0, 100.0, 0.0),
(usd_account, 0.0, old_balance, 0.0, 100.0),
(gain_loss_account, gain_loss_debit, gain_loss_credit, gain_loss_debit, gain_loss_credit),
]
actual = []
for acc in je.accounts:
actual.append(
(
acc.account,
acc.debit,
acc.credit,
acc.debit_in_account_currency,
acc.credit_in_account_currency,
)
)
self.assertEqual(expected, actual)
# Assert reversals are not posted
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertFalse(ret.get("reversals_posted"))
err.make_reverse_journal()
# submit
draft = frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"},
pluck="name",
as_list=1,
)
self.assertIsNotNone(draft)
frappe.get_doc("Journal Entry", draft[0]).submit()
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertTrue(ret.get("reversals_posted"))
reverse_jv = frappe.db.get_all(
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
)
self.assertIsNotNone(reverse_jv)

View File

@@ -1853,28 +1853,51 @@ class GrowthViewTransformer:
self.formatted_rows = context.raw_data.get("formatted_data", [])
self.period_list = context.period_list
def transform(self) -> None:
def transform(self):
for row_data in self.formatted_rows:
if row_data.get("is_blank_line"):
continue
transformed_values = {}
for i in range(len(self.period_list)):
current_period = self.period_list[i]["key"]
if row_data.get("segment_values"):
self._transform_segmented_row(row_data)
else:
self._transform_single_row(row_data)
current_value = row_data[current_period]
previous_value = row_data[self.period_list[i - 1]["key"]] if i != 0 else 0
def _compute_growth_values(self, source: dict) -> dict:
transformed = {}
if i == 0:
transformed_values[current_period] = current_value
else:
growth_percent = self._calculate_growth(previous_value, current_value)
transformed_values[current_period] = growth_percent
for i, period in enumerate(self.period_list):
current_period = period["key"]
current_value = source.get(current_period)
row_data.update(transformed_values)
if current_value in (None, ""):
continue
if i == 0:
transformed[current_period] = current_value
else:
previous_period = self.period_list[i - 1]["key"]
previous_value = source.get(previous_period) or 0
transformed[current_period] = self._calculate_growth(previous_value, current_value)
return transformed
def _transform_single_row(self, row_data: dict):
row_data.update(self._compute_growth_values(row_data))
def _transform_segmented_row(self, row_data: dict):
for seg_id, seg_data in row_data.get("segment_values", {}).items():
if seg_data.get("is_blank_line"):
continue
transformed = self._compute_growth_values(seg_data)
seg_data.update(transformed)
for period_key, value in transformed.items():
row_data[f"{seg_id}_{period_key}"] = value
def _calculate_growth(self, previous_value: float, current_value: float) -> float | None:
if current_value is None:
if current_value in (None, ""):
return None
if previous_value == 0 and current_value > 0:

View File

@@ -567,6 +567,7 @@ $.extend(erpnext.journal_entry, {
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => field.df.fieldname != "posting_date")
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -217,7 +217,8 @@ class POSClosingEntry(StatusUpdater):
self.update_sales_invoices_closing_entry()
def before_cancel(self):
self.check_pce_is_cancellable()
if self.status != "Failed":
self.check_pce_is_cancellable()
def on_cancel(self):
unconsolidate_pos_invoices(closing_entry=self)

View File

@@ -517,6 +517,7 @@ class SalesInvoice(SellingController):
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
self.update_billing_status_for_zero_amount_refdoc("Sales Order")
self.check_credit_limit()
self.check_overdue_billing_threshold()
if cint(self.is_pos) != 1 and not self.is_return:
self.update_against_document_in_jv()
@@ -778,6 +779,11 @@ class SalesInvoice(SellingController):
pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
pos_invoice_doc.cancel()
def check_overdue_billing_threshold(self):
from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold
check_overdue_billing_threshold(self.customer, self.company)
@frappe.whitelist()
def set_missing_values(self, for_validate=False):
pos = self.set_pos_fields(for_validate)

View File

@@ -82,8 +82,7 @@
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"reqd": 1
"options": "Cost Center"
},
{
"fieldname": "shipping_amount_section",
@@ -141,19 +140,20 @@
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"icon": "fa fa-truck",
"idx": 1,
"links": [],
"modified": "2024-03-27 13:10:41.653314",
"modified": "2026-07-22 14:53:27.315435",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Shipping Rule",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -197,7 +197,8 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -36,18 +36,17 @@ class ShippingRule(Document):
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
ShippingRuleCondition,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
ShippingRuleCountry,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
account: DF.Link
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
company: DF.Link
conditions: DF.Table[ShippingRuleCondition]
cost_center: DF.Link
cost_center: DF.Link | None
countries: DF.Table[ShippingRuleCountry]
disabled: DF.Check
label: DF.Data
project: DF.Link | None
shipping_amount: DF.Currency
shipping_rule_type: DF.Literal["Selling", "Buying"]
# end: auto-generated types

View File

@@ -858,7 +858,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
companies = frappe.get_all(
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
)

View File

@@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -55,8 +55,7 @@ class ReceivablePayableReport:
self.filters.report_date = getdate(self.filters.report_date or nowdate())
self.age_as_on = (
getdate(nowdate())
if "calculate_ageing_with" not in self.filters
or self.filters.calculate_ageing_with == "Today Date"
if "age_as_on" not in self.filters or self.filters.age_as_on == "Today"
else self.filters.report_date
)

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -80,6 +80,7 @@ def execute(filters=None):
"parent_section": None,
"indent": 0.0,
"section": cash_flow_section["section_header"],
"currency": company_currency,
}
)

View File

@@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
)
if total_base_amount
else 0,
"currency": filters.currency,
}
)
)
@@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
"buying_amount": total_buying_amount,
"gross_profit": total_gross_profit,
"gross_profit_percent": flt(gross_profit_percent, currency_precision),
"currency": filters.currency,
}
total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]]

View File

@@ -14,7 +14,6 @@ def execute(filters=None):
conditions = get_columns(filters, "Purchase Order")
data = get_data(filters, conditions)
chart_data = get_chart_data(data, conditions, filters)
return conditions["columns"], data, None, chart_data
@@ -39,9 +38,15 @@ def get_chart_data(data, conditions, filters):
labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns]
datapoints = [0] * len(labels)
group_by_col_idx = None
if filters.get("group_by"):
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
for row in data:
# If group by filter, don't add first row of group (it's already summed)
if not row[start]:
# Skip the final grand-total row
if row[0] == f"'{_('Total')}'":
continue
if group_by_col_idx is not None and row[group_by_col_idx] == "":
continue
# Remove None values and compute only periodic data
row = [x if x else 0 for x in row[start:-2]]
@@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters):
"type": "line",
"lineOptions": {"regionFill": 1},
"fieldtype": "Currency",
"options": "currency",
"currency": conditions.get("company_currency"),
}

View File

@@ -331,32 +331,51 @@ class BuyingController(SubcontractingController):
address_display_field, render_address(self.get(address_field), check_permissions=False)
)
def get_validated_purchase_expense_details(self, item_code):
fields = ("purchase_expense_account", "purchase_expense_contra_account")
details = get_purchase_expense_account(item_code, self.company)
for field in fields:
if not details.get(field):
details[field] = frappe.get_cached_value("Company", self.company, field)
for field in fields:
if not details.get(field):
frappe.throw(
_("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format(
frappe.bold(_(frappe.unscrub(field))), self.company, item_code
)
)
return details
def set_gl_entry_for_purchase_expense(self, gl_entries):
if not cint(frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")):
return
if self.doctype == "Purchase Invoice" and not self.update_stock:
return
stock_items = self.get_stock_items()
for row in self.items:
details = get_purchase_expense_account(row.item_code, self.company)
# A service item holds no stock value, so there is nothing to book against it - and it
# must not make the expense accounts mandatory either.
if row.item_code not in stock_items:
continue
if not details.purchase_expense_account:
details.purchase_expense_account = frappe.get_cached_value(
"Company", self.company, "purchase_expense_account"
)
if not details.purchase_expense_account:
return
if not details.purchase_expense_contra_account:
details.purchase_expense_contra_account = frappe.get_cached_value(
"Company", self.company, "purchase_expense_contra_account"
)
if not details.purchase_expense_contra_account:
frappe.throw(
_("Please set Purchase Expense Contra Account in Company {0}").format(self.company)
)
details = self.get_validated_purchase_expense_details(row.item_code)
if not details:
continue
amount = flt(row.valuation_rate * row.stock_qty, row.precision("base_amount"))
if row.landed_cost_voucher_amount:
amount -= flt(row.landed_cost_voucher_amount, row.precision("base_amount"))
if not amount:
# GL Entry rejects a row with neither a debit nor a credit.
continue
self.add_gl_entry(
gl_entries=gl_entries,
account=details.purchase_expense_account,

View File

@@ -84,6 +84,11 @@ def stock_entry_row_requires_inspection(purpose, row):
class StockController(AccountsController):
#: Vouchers whose stock value change should also be booked to the Expenses Added To Stock
#: account pair (Stock Entry, Stock Reconciliation). Purchase Receipt books its own, against
#: the landed cost amount rather than the stock value difference.
book_expenses_added_to_stock = False
def validate(self):
super().validate()
@@ -858,10 +863,90 @@ class StockController(AccountsController):
).format(wh, self.company)
)
if self.book_expenses_added_to_stock:
self.append_expenses_added_to_stock_entries(gl_list, voucher_details, sle_map)
return process_gl_map(
gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation
)
def book_stock_expense_enabled(self):
if not hasattr(self, "_book_stock_expense_enabled"):
self._book_stock_expense_enabled = cint(
frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")
)
return self._book_stock_expense_enabled
def append_expenses_added_to_stock_entries(self, gl_list, voucher_details, sle_map):
if not self.book_stock_expense_enabled():
return
precision = self.get_debit_field_precision()
for item_row in voucher_details:
sle_list = sle_map.get(item_row.name)
if not sle_list:
continue
amount = flt(sum(flt(sle.stock_value_difference) for sle in sle_list), precision)
if not amount:
continue
item_code = item_row.get("item_code") or sle_list[0].item_code
self.append_expenses_added_to_stock_pair(gl_list, item_code, amount, item_row)
def append_expenses_added_to_stock_pair(self, gl_list, item_code, amount, item_row):
# A service item holds no stock value, so there is nothing to book against it - and it must
# not make the expense accounts mandatory either. A zero pair would be rejected by GL Entry
# anyway, which needs a debit or a credit on every row.
if not amount or not frappe.get_cached_value("Item", item_code, "is_stock_item"):
return
fields = ("expenses_added_to_stock_account", "expenses_added_to_stock_contra_account")
details = get_expenses_added_to_stock_accounts(item_code, self.company)
for field in fields:
if not details.get(field):
frappe.throw(
_("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format(
frappe.bold(_(frappe.unscrub(field))), self.company, item_code
)
)
cost_center = item_row.get("cost_center") or frappe.get_cached_value(
"Company", self.company, "cost_center"
)
remarks = _("Expenses Added To Stock for Item {0}").format(item_code)
common_args = {
"cost_center": cost_center,
"project": item_row.get("project") or self.get("project"),
"remarks": remarks,
}
gl_list.append(
self.get_gl_dict(
{
"account": details.expenses_added_to_stock_account,
"against": details.expenses_added_to_stock_contra_account,
"debit": amount,
**common_args,
},
item=item_row,
)
)
gl_list.append(
self.get_gl_dict(
{
"account": details.expenses_added_to_stock_contra_account,
"against": details.expenses_added_to_stock_account,
"debit": -1 * amount,
**common_args,
},
item=item_row,
)
)
def get_debit_field_precision(self):
if not frappe.flags.debit_field_precision:
frappe.flags.debit_field_precision = frappe.get_precision("GL Entry", "debit_in_account_currency")
@@ -1371,8 +1456,9 @@ class StockController(AccountsController):
if outstanding > 0:
reservations[key].append(row)
precision = frappe.get_precision("Serial and Batch Entry", "qty")
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
if flt(reserved_qty, 6) <= 0:
if flt(reserved_qty, precision) <= 0:
continue
batch_qty = get_batch_qty(
@@ -1383,7 +1469,7 @@ class StockController(AccountsController):
consider_negative_batches=True,
)
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
continue
vouchers = ", ".join(
@@ -2567,3 +2653,31 @@ def get_item_wise_inventory_account_map(rows, company):
)
return inventory_map
@frappe.request_cache
def get_expenses_added_to_stock_accounts(item_code, company):
"""Resolves the Expenses Added To Stock account pair for an item, falling back through
Item Defaults -> Item Group -> Brand -> Company."""
from erpnext.stock.doctype.item.item import get_item_defaults
fields = ["expenses_added_to_stock_account", "expenses_added_to_stock_contra_account"]
defaults = get_item_defaults(item_code, company)
details = frappe._dict({field: defaults.get(field) for field in fields})
if not details.expenses_added_to_stock_account:
details = frappe.db.get_value(
"Item Default", {"parent": defaults.item_group, "company": company}, fields, as_dict=1
) or frappe._dict({})
if not details.expenses_added_to_stock_account and defaults.get("brand"):
details = frappe.db.get_value(
"Item Default", {"parent": defaults.brand, "company": company}, fields, as_dict=1
) or frappe._dict({})
for field in fields:
if not details.get(field):
details[field] = frappe.get_cached_value("Company", company, field)
return details

View File

@@ -6,6 +6,7 @@ import frappe
from frappe import _
from frappe.utils import DateTimeLikeObject, getdate, today
import erpnext
from erpnext.accounts.utils import get_fiscal_year
@@ -42,6 +43,9 @@ def get_columns(filters, trans):
"addl_tables": based_on_details["addl_tables"],
"addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""),
}
conditions["company_currency"] = (
erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None
)
return conditions
@@ -206,7 +210,7 @@ def get_data(filters, conditions):
data.append(des)
total_row = calculate_total_row(data1, conditions["columns"])
total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency"))
data.append(total_row)
else:
data = frappe.db.sql(
@@ -231,19 +235,24 @@ def get_data(filters, conditions):
as_list=1,
)
total_row = calculate_total_row(data, conditions["columns"])
total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency"))
data.append(total_row)
return data
def calculate_total_row(data, columns):
def calculate_total_row(data, columns, company_currency=None):
def wrap_in_quotes(label):
return f"'{label}'"
total_values = {}
currency_col_idx = None
for i, col in enumerate(columns):
if "Float" in col or "Currency/currency" in col:
# based-on and group-by columns are dicts, periodic and total columns are strings
if isinstance(col, dict):
if col.get("fieldtype") == "Link" and col.get("options") == "Currency":
currency_col_idx = i
elif "Float" in col or "Currency/currency" in col:
total_values[i] = 0
for row in data:
@@ -254,6 +263,9 @@ def calculate_total_row(data, columns):
for i in range(1, len(columns)):
total_row.append(total_values.get(i, None))
if currency_col_idx is not None:
total_row[currency_col_idx] = company_currency
return total_row

View File

@@ -279,13 +279,17 @@ class Opportunity(TransactionBase, CRMNote):
self.save()
else:
frappe.throw(_("Cannot declare as lost, because Quotation has been made."))
frappe.throw(_("Cannot declare as Lost because an active Quotation exists."))
def has_active_quotation(self):
if not self.get("items", []):
return frappe.get_all(
"Quotation",
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1},
{
"opportunity": self.name,
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
"docstatus": 1,
},
"name",
)
else:
@@ -294,14 +298,20 @@ class Opportunity(TransactionBase, CRMNote):
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status not in ('Lost', 'Closed')""",
and q.status not in ('Lost', 'Cancelled', 'Expired')""",
self.name,
)
def has_ordered_quotation(self):
if not self.get("items", []):
return frappe.get_all(
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
"Quotation",
{
"opportunity": self.name,
"status": ("in", ["Ordered", "Partially Ordered"]),
"docstatus": 1,
},
"name",
)
else:
return frappe.db.sql(
@@ -309,7 +319,7 @@ class Opportunity(TransactionBase, CRMNote):
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status = 'Ordered'""",
and q.status in ('Ordered', 'Partially Ordered')""",
self.name,
)

File diff suppressed because it is too large Load Diff

View File

@@ -1003,7 +1003,7 @@ class BOM(WebsiteGenerator):
for d in self.get("items"):
old_rate = d.rate
if not self.bom_creator and (d.is_stock_item or d.is_phantom_item):
if d.is_stock_item or d.is_phantom_item:
d.rate = self.get_rm_rate(
{
"company": self.company,

View File

@@ -537,15 +537,8 @@ class BOMCreator(Document):
row.delete()
updated = True
items = get_children(parent=kwargs.fg_item, parent_id=self.name)
if items:
for item in items:
updated = True
child_row = next((row for row in self.items if row.name == item.name), None)
if child_row:
child_row.delete()
if item.expandable:
self.delete_node(fg_item=item.value)
if self.delete_child_nodes(kwargs.docname or self.name):
updated = True
if updated:
self.set_rate_for_items()
@@ -555,6 +548,19 @@ class BOMCreator(Document):
return frappe._dict()
def delete_child_nodes(self, fg_reference_id: str):
deleted = False
for item in get_children(parent=fg_reference_id, parent_id=self.name):
child_row = next((row for row in self.items if row.name == item.name), None)
if child_row:
child_row.delete()
deleted = True
if item.expandable:
self.delete_child_nodes(item.name)
return deleted
@frappe.whitelist()
def get_children(doctype: str | None = None, parent: str | None = None, **kwargs):
@@ -567,7 +573,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
kwargs = frappe._dict(kwargs)
fields = [
"item_code as value",
"name as value",
"item_name as title",
"is_expandable as expandable",
"parent as parent_id",
@@ -575,6 +581,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
"idx",
ValueWrapper("BOM Creator Item").as_("doctype"),
"name",
"item_code",
"uom",
"rate",
"amount",
@@ -583,7 +590,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
]
query_filters = {
"fg_item": parent,
"fg_reference_id": parent,
"parent": kwargs.parent_id,
}

View File

@@ -241,6 +241,26 @@ class TestBOMCreator(ERPNextTestSuite):
data = frappe.get_all("BOM", filters={"bom_creator": doc.name, "docstatus": 1})
self.assertEqual(len(data), 2)
def test_repeated_sub_assembly_keeps_own_raw_materials(self):
doc, first_wheel, second_wheel = make_repeated_sub_assembly_bom(
"Bicycle BOM with Repeated Sub Assembly"
)
self.assertEqual(child_items(doc, doc.name), ["Frame Assembly", "Seat Assembly"])
self.assertEqual(child_items(doc, first_wheel), ["Rim", "Spokes"])
self.assertEqual(child_items(doc, second_wheel), ["Hub"])
def test_delete_repeated_sub_assembly_keeps_sibling_raw_materials(self):
doc, first_wheel, second_wheel = make_repeated_sub_assembly_bom(
"Bicycle BOM with Deleted Sub Assembly"
)
doc.delete_node(doctype="BOM Creator Item", docname=second_wheel)
doc.reload()
self.assertEqual(child_items(doc, first_wheel), ["Rim", "Spokes"])
self.assertFalse([row for row in doc.items if row.item_code == "Hub"])
def test_edit_and_delete_reject_unknown_item(self):
final_product = "Bicycle"
make_item(
@@ -327,6 +347,55 @@ def create_items():
)
def make_repeated_sub_assembly_bom(name):
"""Bicycle > (Frame Assembly > Wheel Assembly > Rim, Spokes), (Seat Assembly > Wheel Assembly > Hub)"""
final_product = "Bicycle"
make_item(final_product, {"item_group": "Raw Material", "stock_uom": "Nos"})
doc = make_bom_creator(
name=name,
company="_Test Company",
item_code=final_product,
qty=1,
rm_cosy_as_per="Valuation Rate",
currency="INR",
plc_conversion_rate=1,
conversion_rate=1,
)
def add_sub_assembly(fg_item, fg_reference_id, item_code, raw_materials):
doc.add_sub_assembly(
fg_item=fg_item,
fg_reference_id=fg_reference_id,
bom_item={
"item_code": item_code,
"qty": 1,
"items": [{"item_code": item, "qty": 1} for item in raw_materials],
},
)
doc.reload()
return next(
row.name
for row in doc.items
if row.item_code == item_code and row.fg_reference_id == fg_reference_id
)
frame = add_sub_assembly(final_product, doc.name, "Frame Assembly", ["Frame"])
first_wheel = add_sub_assembly("Frame Assembly", frame, "Wheel Assembly", ["Rim", "Spokes"])
seat = add_sub_assembly(final_product, doc.name, "Seat Assembly", ["Seat"])
second_wheel = add_sub_assembly("Seat Assembly", seat, "Wheel Assembly", ["Hub"])
return doc, first_wheel, second_wheel
def child_items(doc, parent):
from erpnext.manufacturing.doctype.bom_creator.bom_creator import get_children
return sorted(row.item_code for row in get_children(parent=parent, parent_id=doc.name))
def make_bom_creator(**kwargs):
if isinstance(kwargs, str) or isinstance(kwargs, dict):
kwargs = frappe.parse_json(kwargs)

View File

@@ -981,18 +981,31 @@ class JobCard(Document):
self.update_work_order_data(for_quantity, process_loss_qty, pending_qty, time_in_mins, wo)
def update_semi_finished_good_details(self):
if self.operation_id:
qty = max(flt(self.manufactured_qty), flt(self.total_completed_qty))
if not self.operation_id:
return
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", qty)
if (
self.finished_good
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
== self.finished_good
):
_wo_doc = frappe.get_doc("Work Order", self.work_order)
_wo_doc.db_set("produced_qty", self.manufactured_qty)
_wo_doc.db_set("status", _wo_doc.get_status())
job_cards = frappe.get_all(
"Job Card",
filters={
"work_order": self.work_order,
"operation_id": self.operation_id,
"docstatus": 1,
"is_corrective_job_card": 0,
},
fields=["manufactured_qty", "total_completed_qty"],
)
completed_qty = sum(max(flt(row.manufactured_qty), flt(row.total_completed_qty)) for row in job_cards)
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty)
if (
self.finished_good
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
== self.finished_good
):
_wo_doc = frappe.get_doc("Work Order", self.work_order)
_wo_doc.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards))
_wo_doc.db_set("status", _wo_doc.get_status())
def update_corrective_in_work_order(self, wo):
wo.corrective_operation_cost = 0.0

View File

@@ -1162,6 +1162,109 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278)
def test_semi_fg_produced_qty_across_split_job_cards(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Split JC RM 1", {"is_stock_item": 1}).name
fg = make_item("Split JC FG 1", {"is_stock_item": 1}).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
operation = {
"operation": "Split JC Op A",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=8,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
job_card = frappe.get_doc(
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
)
job_card.for_quantity = 5
job_card.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 5},
)
job_card.save()
job_card.submit()
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 5)
make_job_card(
work_order.name,
[
{
"name": work_order.operations[0].name,
"operation": "Split JC Op A",
"qty": 3,
"pending_qty": 3,
"skip_material_transfer": 1,
}
],
)
job_card = frappe.get_doc(
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name, "docstatus": 0})
)
job_card.append(
"time_logs",
{
"from_time": "2024-02-02 08:00:00",
"to_time": "2024-02-02 09:00:00",
"completed_qty": job_card.for_quantity,
},
)
job_card.save()
job_card.submit()
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 8)
self.assertEqual(work_order.status, "Completed")
self.assertEqual(
flt(frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "completed_qty")),
8,
)
def test_semi_fg_batch_auto_pull_on_manufacture(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -32,6 +32,7 @@ from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_childr
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_uom_conv_factor
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
from erpnext.stock.get_item_details import get_conversion_factor
from erpnext.stock.utils import get_or_make_bin
@@ -1333,9 +1334,16 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p
def get_uom_conversion_factor(item_code, uom):
return frappe.db.get_value(
item = frappe.get_cached_value("Item", item_code, ["variant_of", "stock_uom"], as_dict=True)
conversion_factor = frappe.db.get_value(
"UOM Conversion Detail", {"parent": item_code, "uom": uom}, "conversion_factor"
)
if not conversion_factor and item.variant_of:
conversion_factor = frappe.db.get_value(
"UOM Conversion Detail", {"parent": item.variant_of, "uom": uom}, "conversion_factor"
)
return conversion_factor or get_uom_conv_factor(uom, item.stock_uom)
def get_subitems(

View File

@@ -1730,6 +1730,118 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertTrue(row.warehouse == mrp_warhouse)
self.assertEqual(row.quantity, 12.0)
def test_purchase_uom_falls_back_to_uom_conversion_factor(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
if not frappe.db.exists("UOM Conversion Factor", {"from_uom": "Kg", "to_uom": "Gram"}):
frappe.get_doc(
doctype="UOM Conversion Factor",
category="Mass",
from_uom="Kg",
to_uom="Gram",
value=1000,
).insert()
rm = make_item("Test RM Item Global CF", {"is_stock_item": 1, "stock_uom": "Gram"})
rm.purchase_uom = "Kg"
rm.save()
self.assertFalse([row for row in rm.uoms if row.uom == "Kg"])
bom_tree = {"Test FG Item Global CF": {rm.name: {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=2000,
ignore_existing_ordered_qty=1,
skip_getting_mr_items=1,
do_not_submit=1,
warehouse="_Test Warehouse - _TC",
)
plan.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
)
row = frappe._dict(next(item for item in items if item["item_code"] == rm.name))
self.assertEqual(row.uom, "Kg")
self.assertEqual(row.conversion_factor, 1000)
self.assertEqual(row.quantity, 2)
def test_variant_inherits_purchase_uom_conversion_factor_of_template(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
template = make_item(
"TRMVCF",
{
"is_stock_item": 1,
"stock_uom": "Nos",
"has_variants": 1,
"attributes": [{"attribute": "Colour"}],
},
)
if not [row for row in template.uoms if row.uom == "Box"]:
template.purchase_uom = "Box"
template.append("uoms", {"uom": "Box", "conversion_factor": 12})
template.save()
if not frappe.db.exists("Item", "TRMVCF-RED"):
create_variant("TRMVCF", {"Colour": "Red"}).insert()
variant = frappe.get_doc("Item", "TRMVCF-RED")
variant.uoms = [row for row in variant.uoms if row.uom != "Box"]
variant.purchase_uom = "Box"
variant.save()
bom_tree = {"Test FG Item Variant CF": {variant.name: {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=24,
ignore_existing_ordered_qty=1,
skip_getting_mr_items=1,
do_not_submit=1,
warehouse="_Test Warehouse - _TC",
)
plan.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
)
row = frappe._dict(next(item for item in items if item["item_code"] == variant.name))
self.assertEqual(row.conversion_factor, 12)
self.assertEqual(row.quantity, 2)
def test_missing_purchase_uom_conversion_factor_throws(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
rm = make_item("Test RM Item Missing CF", {"is_stock_item": 1, "stock_uom": "Nos"})
rm.purchase_uom = "Box"
rm.save()
bom_tree = {"Test FG Item Missing CF": {rm.name: {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=10,
ignore_existing_ordered_qty=1,
skip_getting_mr_items=1,
do_not_submit=1,
warehouse="_Test Warehouse - _TC",
)
plan.for_warehouse = "_Test Warehouse - _TC"
with self.assertRaises(frappe.ValidationError) as error:
get_items_for_material_requests(
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
)
self.assertIn("UOM Conversion factor", str(error.exception))
def test_mr_qty_for_complex_bom(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse

View File

@@ -79,6 +79,11 @@ frappe.ui.form.on("BOM Operation", {
const d = locals[cdt][cdn];
frm.events.calculate_operating_cost(frm, d);
},
hour_rate: function (frm, cdt, cdn) {
const d = locals[cdt][cdn];
frm.events.calculate_operating_cost(frm, d);
},
});
frappe.tour["Routing"] = [

View File

@@ -2554,7 +2554,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
@frappe.whitelist()
def make_work_order(
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
bom_no: str,
item: str,
qty: float = 0,
company: str | None = None,
project: str | None = None,
variant_items: str | list | None = None,
use_multi_level_bom: bool | None = None,
):
from erpnext import get_default_company
@@ -2563,7 +2569,8 @@ def make_work_order(
item_details = get_item_details(item, project)
if frappe.db.get_value("Item", item, "variant_of"):
# selected BOM already belongs to this variant — keep it
if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item:
if variant_bom := frappe.db.get_value(
"BOM",
{"item": item, "is_default": 1, "docstatus": 1},

View File

@@ -80,7 +80,7 @@ class TestWorkstation(ERPNextTestSuite):
test_routing_operations = [
{"operation": "Test Operation A", "workstation": "_Test Workstation A", "time_in_mins": 60},
{"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 60},
{"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 30},
]
routing_doc = create_routing(routing_name="Routing Test", operations=test_routing_operations)
bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency="INR")
@@ -110,6 +110,17 @@ class TestWorkstation(ERPNextTestSuite):
self.assertEqual(bom_doc.operations[0].hour_rate, 250)
self.assertEqual(bom_doc.operations[1].hour_rate, 250)
# hour_rate propagation must also refresh operating_cost (hour_rate * time_in_mins / 60)
# on the Routing's BOM Operation rows; the 30-min op exercises the arithmetic.
for operation, expected_operating_cost in (("Test Operation A", 250), ("Test Operation B", 125)):
hour_rate, operating_cost = frappe.db.get_value(
"BOM Operation",
{"parent": routing_doc.name, "parenttype": "Routing", "operation": operation},
["hour_rate", "operating_cost"],
)
self.assertEqual(hour_rate, 250)
self.assertEqual(operating_cost, expected_operating_cost)
def make_workstation(*args, **kwargs):
args = args if args else kwargs

View File

@@ -195,9 +195,10 @@ class Workstation(Document):
for bom_no in bom_list:
frappe.db.sql(
"""update `tabBOM Operation` set hour_rate = %s
"""update `tabBOM Operation`
set hour_rate = %s, operating_cost = %s * time_in_mins / 60
where parent = %s and workstation = %s""",
(self.hour_rate, bom_no[0], self.name),
(self.hour_rate, self.hour_rate, bom_no[0], self.name),
)
def validate_workstation_holiday(self, schedule_date, skip_holiday_list_check=False):

View File

@@ -491,3 +491,6 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
erpnext.patches.v16_0.fix_subcontracting_titles

View File

@@ -0,0 +1,10 @@
import frappe
def execute():
has_expense_accounts = frappe.db.exists(
"Company", {"purchase_expense_account": ("is", "set")}
) or frappe.db.exists("Item Default", {"purchase_expense_account": ("is", "set")})
if has_expense_accounts:
frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 1)

View File

@@ -0,0 +1,28 @@
import frappe
def execute():
"""
This patch corrects the titles of the subcontracting order doctypes set to
the text strings "{customer_name}" or "{supplier_name}" instead of the
actual customer or supplier name.
Their `title_field` never pointed at `title`, so the template default was
stored verbatim instead of being substituted.
"""
party_fields = {
"Subcontracting Order": "supplier_name",
"Subcontracting Inward Order": "customer_name",
}
for doctype, party_field in party_fields.items():
if not frappe.db.has_column(doctype, "title"):
continue
table = frappe.qb.DocType(doctype)
(
frappe.qb.update(table)
.set(table.title, table[party_field])
.where(table.title == f"{{{party_field}}}")
).run()

View File

@@ -0,0 +1,45 @@
import frappe
REPORTS = (
"Accounts Receivable",
"Accounts Payable",
"Accounts Receivable Summary",
"Accounts Payable Summary",
)
def execute():
# filter `calculate_ageing_with` -> `age_as_on`, option "Today Date" -> "Today"
_migrate("Auto Email Report", "filters", "report")
_migrate("Dashboard Chart", "filters_json", "report_name", type_field="chart_type")
_migrate("Number Card", "filters_json", "report_name", type_field="type")
def _migrate(doctype, filter_field, report_field, type_field=None):
conditions = {report_field: ("in", REPORTS)}
if type_field:
conditions[type_field] = "Report"
for row in frappe.get_all(doctype, filters=conditions, fields=["name", filter_field]):
updated = _rewrite(row.get(filter_field))
if updated is not None:
frappe.db.set_value(doctype, row.name, filter_field, updated, update_modified=False)
def _rewrite(raw):
if not raw:
return None
try:
filters = frappe.parse_json(raw)
except ValueError:
return None
if not isinstance(filters, dict) or "calculate_ageing_with" not in filters:
return None
filters["age_as_on"] = filters.pop("calculate_ageing_with")
if filters["age_as_on"] == "Today Date":
filters["age_as_on"] = "Today"
return frappe.as_json(filters, indent=None)

View File

@@ -57,6 +57,7 @@ class BOMConfigurator {
breadcrumb: "Manufacturing",
get_tree_nodes: "erpnext.manufacturing.doctype.bom_creator.bom_creator.get_children",
root_label: this.frm.doc.item_code,
get_label: (node) => this.get_node_label(node),
disable_add_node: true,
get_tree_root: false,
show_expand_all: false,
@@ -66,6 +67,23 @@ class BOMConfigurator {
};
}
get_node_label(node) {
const item_code = this.get_item_code(node);
const item_name = node.data?.title || item_code;
if (item_name === item_code) {
return frappe.utils.escape_html(item_code);
}
return `${frappe.utils.escape_html(item_name)} <span class='text-muted'>(${frappe.utils.escape_html(
item_code
)})</span>`;
}
get_item_code(node) {
return node.data?.item_code || this.frm.doc.item_code;
}
tree_methods() {
let frm_obj = this;
let view = frappe.views.trees["BOM Configurator"];
@@ -73,7 +91,8 @@ class BOMConfigurator {
return {
onload: function (me) {
me.args["parent_id"] = frm_obj.frm.doc.name;
me.args["parent"] = frm_obj.frm.doc.item_code;
me.args["parent"] = frm_obj.frm.doc.name;
me.root_value = frm_obj.frm.doc.name;
me.parent = frm_obj.$wrapper.get(0);
me.body = frm_obj.$wrapper.get(0);
me.make_tree();
@@ -83,7 +102,7 @@ class BOMConfigurator {
const uom = node.data.uom || frm_obj.frm.doc.uom;
const docname = node.data.name || frm_obj.frm.doc.name;
let amount = node.data.amount;
if (node.data.value === frm_obj.frm.doc.item_code) {
if (node.is_root) {
amount = frm_obj.frm.doc.raw_material_cost;
}
@@ -243,7 +262,7 @@ class BOMConfigurator {
method: "add_item",
doc: this.frm.doc,
args: {
fg_item: node.data.value,
fg_item: this.get_item_code(node),
item_code: data.item_code,
fg_reference_id: node.data.name || this.frm.doc.name,
qty: data.qty,
@@ -298,7 +317,7 @@ class BOMConfigurator {
method: "add_sub_assembly",
doc: this.frm.doc,
args: {
fg_item: node.data.value,
fg_item: this.get_item_code(node),
fg_reference_id: node.data.name || this.frm.doc.name,
bom_item: bom_item,
operation: node.data.operation,
@@ -417,7 +436,7 @@ class BOMConfigurator {
});
dialog.set_values({
item_code: node.data.value,
item_code: this.get_item_code(node),
qty: node.data.qty,
});
@@ -445,7 +464,7 @@ class BOMConfigurator {
method: "add_sub_assembly",
doc: this.frm.doc,
args: {
fg_item: node.data.value,
fg_item: this.get_item_code(node),
bom_item: bom_item,
fg_reference_id: node.data.name || this.frm.doc.name,
convert_to_sub_assembly: true,
@@ -482,7 +501,6 @@ class BOMConfigurator {
method: "delete_node",
doc: this.frm.doc,
args: {
fg_item: node.data.value,
doctype: node.data.doctype,
docname: node.data.name,
},

View File

@@ -11,6 +11,36 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con
}
}
onload_post_render() {
this.set_route_options_for_new_doc();
}
set_route_options_for_new_doc() {
// While creating a Batch or Serial and Batch Bundle from the link
// field, copy details from the line item to the new form
if (!this.frm.fields_dict.items) return;
let batch_no_field = this.frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = (row) => {
return {
item: row.doc.item_code,
};
};
}
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
warehouse: row.doc.warehouse || row.doc.s_warehouse || row.doc.t_warehouse,
voucher_type: this.frm.doc.doctype,
};
};
}
}
barcode(doc, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.barcode) {

View File

@@ -649,34 +649,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
erpnext.toggle_serial_batch_fields(this.frm);
}
set_route_options_for_new_doc() {
// While creating the batch from the link field, copy item from line item to batch form
if (this.frm.fields_dict["items"].grid.get_field("batch_no")) {
let batch_no_field = this.frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = function (row) {
return {
item: row.doc.item_code,
};
};
}
}
// While creating the SABB from the link field, copy item, doctype from line item to SABB form
if (this.frm.fields_dict["items"].grid.get_field("serial_and_batch_bundle")) {
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
voucher_type: this.frm.doc.doctype,
};
};
}
}
}
scan_barcode() {
frappe.flags.dialog_set = false;
this.barcode_scanner.process_scan();

View File

@@ -41,6 +41,26 @@ erpnext.financial_statements = {
_is_special_view: function (column, data) {
if (!data) return false;
const view = get_filter_value("selected_view");
if (!["Growth", "Margin"].includes(view)) return false;
if (get_filter_value("report_template")) {
const columnInfo = erpnext.financial_statements._parse_column_info(column.fieldname, data);
// Account column
if (columnInfo.isAccount) return false;
const periodKeys = data._segment_info?.period_keys || [];
if (!periodKeys.includes(columnInfo.fieldname)) return false;
if (view === "Growth") {
// First period of new segment
if (periodKeys[0] === columnInfo.fieldname) return false;
}
return true;
}
return (view === "Growth" && column.colIndex >= 3) || (view === "Margin" && column.colIndex >= 2);
},

View File

@@ -475,10 +475,10 @@
"report_hide": 1
},
{
"description": "Transactions are blocked or warned when outstanding balance exceeds this amount.",
"description": "Transactions are blocked when the outstanding balance exceeds the credit limit. When Restrict Customer Over Billing is enabled, new invoices are also blocked when the customer's overdue amount exceeds the Overdue Limit.",
"fieldname": "credit_limits",
"fieldtype": "Table",
"label": "Credit Limit",
"label": "Credit & Overdue Limits",
"options": "Customer Credit Limit",
"show_description_on_click": 1
},

View File

@@ -16,7 +16,7 @@ from frappe.model.mapper import get_mapped_doc
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
from frappe.model.utils.rename_doc import update_linked_doctypes
from frappe.query_builder import Field, functions
from frappe.utils import cint, cstr, flt, get_formatted_email, today
from frappe.utils import cint, cstr, flt, fmt_money, get_formatted_email, getdate, today
from frappe.utils.user import get_users_with_role
from erpnext.accounts.party import (
@@ -204,17 +204,21 @@ class Customer(TransactionBase):
self.credit_limits = []
self.payment_terms = self.default_price_list = ""
tables = [["accounts", "account"], ["credit_limits", "credit_limit"]]
tables = [
["accounts", ["account"]],
["credit_limits", ["credit_limit", "overdue_billing_threshold"]],
]
fields = ["payment_terms", "default_price_list"]
for row in tables:
table, field = row[0], row[1]
table, table_fields = row[0], row[1]
if not doc.get(table):
continue
for entry in doc.get(table):
child = self.append(table)
child.update({"company": entry.company, field: entry.get(field)})
child.update({"company": entry.company})
child.update({field: entry.get(field) for field in table_fields})
for field in fields:
if not doc.get(field):
@@ -403,6 +407,9 @@ class Customer(TransactionBase):
else:
company_record.append(limit.company)
if not flt(limit.credit_limit):
continue
outstanding_amt = get_customer_outstanding(
self.name, limit.company, ignore_outstanding_sales_order=limit.bypass_credit_limit_check
)
@@ -674,6 +681,124 @@ def send_emails(customer, customer_outstanding, credit_limit, credit_controller_
frappe.sendmail(recipients=credit_controller_users_list, subject=subject, message=message)
def check_overdue_billing_threshold(customer: str, company: str) -> None:
if not frappe.get_single_value("Accounts Settings", "enable_overdue_billing_threshold"):
return
threshold = get_overdue_billing_threshold(customer, company)
if not threshold:
return
overdue_amount = get_customer_overdue_amount(customer, company)
if overdue_amount <= threshold:
return
bypass_role = frappe.get_single_value("Accounts Settings", "role_allowed_to_bypass_overdue_billing")
if bypass_role and bypass_role in frappe.get_roles():
return
company_currency = frappe.get_cached_value("Company", company, "default_currency")
frappe.throw(
_("Overdue Limit crossed for customer {0}. Overdue amount {1} exceeds the allowed limit {2}.").format(
customer,
fmt_money(overdue_amount, currency=company_currency),
fmt_money(threshold, currency=company_currency),
),
title=_("Overdue Limit Crossed"),
)
def get_overdue_billing_threshold(customer: str, company: str) -> float:
"""Overdue limit set on the customer, falling back to its customer group."""
threshold = frappe.db.get_value(
"Customer Credit Limit",
{"parent": customer, "parenttype": "Customer", "company": company},
"overdue_billing_threshold",
)
if not threshold:
customer_group = frappe.get_cached_value("Customer", customer, "customer_group")
threshold = frappe.db.get_value(
"Customer Credit Limit",
{"parent": customer_group, "parenttype": "Customer Group", "company": company},
"overdue_billing_threshold",
)
return flt(threshold)
def get_customer_overdue_amount(customer: str, company: str) -> float:
"""Amount the customer owes past its due date, in company currency.
Follows the same rule as the Overdue invoice status, so a customer is only
blocked for what the invoice list already shows as overdue.
"""
invoices = get_outstanding_invoices_for_customer(customer, company)
if not invoices:
return 0.0
payable_amounts = get_past_due_payable_amounts([d.name for d in invoices])
return flt(sum(get_overdue_portion(d, payable_amounts.get(d.name)) for d in invoices))
def get_outstanding_invoices_for_customer(customer: str, company: str) -> list[frappe._dict]:
from frappe.query_builder.functions import Sum
gl_entry = frappe.qb.DocType("GL Entry")
sales_invoice = frappe.qb.DocType("Sales Invoice")
# debit - credit is always booked in company currency, so this is comparable to the overdue limit
outstanding = Sum(gl_entry.debit) - Sum(gl_entry.credit)
return (
frappe.qb.from_(gl_entry)
.inner_join(sales_invoice)
.on(sales_invoice.name == gl_entry.against_voucher)
.select(
sales_invoice.name,
sales_invoice.due_date,
sales_invoice.base_grand_total,
outstanding.as_("outstanding"),
)
.where(gl_entry.party_type == "Customer")
.where(gl_entry.party == customer)
.where(gl_entry.company == company)
.where(gl_entry.is_cancelled == 0)
.where(gl_entry.against_voucher_type == "Sales Invoice")
.groupby(sales_invoice.name, sales_invoice.due_date, sales_invoice.base_grand_total)
.having(outstanding > 0)
).run(as_dict=True)
def get_past_due_payable_amounts(invoices: list[str]) -> dict[str, float]:
from frappe.query_builder.functions import Sum
payment_schedule = frappe.qb.DocType("Payment Schedule")
rows = (
frappe.qb.from_(payment_schedule)
.select(payment_schedule.parent, Sum(payment_schedule.base_payment_amount).as_("payable"))
.where(payment_schedule.parenttype == "Sales Invoice")
.where(payment_schedule.parent.isin(invoices))
.where(payment_schedule.due_date < getdate())
.groupby(payment_schedule.parent)
).run(as_dict=True)
return {d.parent: flt(d.payable) for d in rows}
def get_overdue_portion(invoice: frappe._dict, payable_amount: float | None) -> float:
outstanding = flt(invoice.outstanding)
# No payable amount means either a schedule-less invoice (POS, opening) or one whose terms are
# all still in the future. Both are answered by the invoice due date, which is the last term.
if payable_amount is None:
return outstanding if invoice.due_date and getdate(invoice.due_date) < getdate() else 0.0
paid = flt(invoice.base_grand_total) - outstanding
return min(max(payable_amount - paid, 0.0), outstanding)
def get_customer_outstanding(customer, company, ignore_outstanding_sales_order=False, cost_center=None):
# Outstanding based on GL Entries
cond = ""

View File

@@ -5,13 +5,15 @@
import json
import frappe
from frappe.utils import flt, nowdate
from frappe.utils import add_days, flt, getdate, nowdate
from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import (
get_credit_limit,
get_customer_outstanding,
get_customer_overdue_amount,
get_overdue_billing_threshold,
make_quotation,
parse_full_name,
)
@@ -77,7 +79,11 @@ class TestCustomer(ERPNextTestSuite):
"company": "_Test Company",
"account": "Creditors - _TC",
}
test_credit_limits = {"company": "_Test Company", "credit_limit": 350000}
test_credit_limits = {
"company": "_Test Company",
"credit_limit": 350000,
"overdue_billing_threshold": 5000,
}
doc.append("accounts", test_account_details)
doc.append("credit_limits", test_credit_limits)
doc.insert()
@@ -97,6 +103,7 @@ class TestCustomer(ERPNextTestSuite):
self.assertEqual(c_doc.credit_limits[0].company, "_Test Company")
self.assertEqual(c_doc.credit_limits[0].credit_limit, 350000)
self.assertEqual(c_doc.credit_limits[0].overdue_billing_threshold, 5000)
c_doc.delete()
doc.delete()
@@ -352,6 +359,139 @@ class TestCustomer(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, customer.save)
def test_get_customer_overdue_amount(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
# a past-due, unpaid invoice adds its outstanding to the overdue amount
create_sales_invoice(qty=1, rate=500, posting_date=add_days(nowdate(), -30))
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
# an invoice due today (not yet past due) does not
create_sales_invoice(qty=1, rate=700, posting_date=nowdate())
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
def test_get_customer_overdue_amount_is_in_company_currency(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
baseline = get_customer_overdue_amount("_Test Customer USD", "_Test Company")
# 100 USD at a conversion rate of 50 must be counted as 5000 in company currency
create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=50,
qty=1,
rate=100,
posting_date=add_days(nowdate(), -30),
)
self.assertEqual(get_customer_overdue_amount("_Test Customer USD", "_Test Company"), baseline + 5000)
def test_get_customer_overdue_amount_follows_payment_terms(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
def make_invoice_with_terms():
si = create_sales_invoice(
qty=1, rate=1200, posting_date=add_days(nowdate(), -60), do_not_save=True
)
si.append("payment_schedule", {"due_date": add_days(nowdate(), -60), "invoice_portion": 50})
si.append("payment_schedule", {"due_date": add_days(nowdate(), 30), "invoice_portion": 50})
si.insert()
si.submit()
return si
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
# only the term that has fallen due counts, not the whole 1200 balance. The invoice due_date
# is the last term (in 30 days), so this is only caught by reading the payment schedule.
si = make_invoice_with_terms()
self.assertEqual(getdate(si.due_date), getdate(add_days(nowdate(), 30)))
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 600)
# paying off the past-due term clears the overdue amount
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC")
pe.reference_no = "_Test Overdue Payment"
pe.reference_date = nowdate()
pe.paid_amount = pe.received_amount = 600
pe.references[0].allocated_amount = 600
pe.insert()
pe.submit()
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline)
def test_overdue_billing_threshold_on_submit(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
create_sales_invoice(qty=1, rate=1000, posting_date=add_days(nowdate(), -30))
overdue = get_customer_overdue_amount("_Test Customer", "_Test Company")
settings = frappe.get_single("Accounts Settings")
original_enable = settings.enable_overdue_billing_threshold
original_bypass_role = settings.role_allowed_to_bypass_overdue_billing
try:
settings.enable_overdue_billing_threshold = 1
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100)
# overdue is over the threshold and the user has no bypass role -> blocked
si = create_sales_invoice(do_not_submit=True)
self.assertRaises(frappe.ValidationError, si.submit)
# a user holding the bypass role can still submit
settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager"
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
# threshold still crossed, but the feature is off -> never blocked
settings.enable_overdue_billing_threshold = 0
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
finally:
settings.enable_overdue_billing_threshold = original_enable
settings.role_allowed_to_bypass_overdue_billing = original_bypass_role
settings.save()
def test_overdue_billing_threshold_falls_back_to_customer_group(self):
customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group")
group = frappe.get_doc("Customer Group", customer_group)
group.credit_limits = []
group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000})
group.save()
# the customer has no threshold of its own, so the group's applies
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
# a threshold on the customer wins over the group
set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000)
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 2000)
# a 0 on the customer inherits the group's limit
set_overdue_billing_threshold("_Test Customer", "_Test Company", 0)
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
def test_overdue_threshold_row_without_credit_limit(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
# outstanding must be > 0 so a 0 credit_limit would previously trip the check
create_sales_invoice(qty=1, rate=500)
customer = frappe.get_doc("Customer", "_Test Customer")
customer.credit_limits = []
customer.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 1000})
customer.save()
self.assertEqual(customer.credit_limits[0].overdue_billing_threshold, 1000)
self.assertEqual(flt(customer.credit_limits[0].credit_limit), 0.0)
def test_customer_payment_terms(self):
frappe.db.set_value(
"Customer", "_Test Customer With Template", "payment_terms", "_Test Payment Term Template 3"
@@ -451,6 +591,18 @@ def set_credit_limit(customer, company, credit_limit):
customer.credit_limits[-1].db_insert()
def set_overdue_billing_threshold(customer, company, threshold):
customer = frappe.get_doc("Customer", customer)
for d in customer.credit_limits:
if d.company == company:
d.overdue_billing_threshold = threshold
d.db_update()
return
customer.append("credit_limits", {"company": company, "overdue_billing_threshold": threshold})
customer.credit_limits[-1].db_insert()
def create_internal_customer(customer_name=None, represents_company=None, allowed_to_interact_with=None):
if not customer_name:
customer_name = represents_company

View File

@@ -8,6 +8,7 @@
"company",
"column_break_2",
"credit_limit",
"overdue_billing_threshold",
"bypass_credit_limit_check"
],
"fields": [
@@ -18,6 +19,15 @@
"in_list_view": 1,
"label": "Credit Limit"
},
{
"columns": 3,
"description": "New Sales Invoices are blocked when the customer's overdue amount exceeds this. Requires 'Restrict Customer Over Billing' in Accounts Settings.",
"fieldname": "overdue_billing_threshold",
"fieldtype": "Currency",
"hidden": 1,
"in_list_view": 1,
"label": "Overdue Limit"
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"

View File

@@ -18,6 +18,7 @@ class CustomerCreditLimit(Document):
bypass_credit_limit_check: DF.Check
company: DF.Link | None
credit_limit: DF.Currency
overdue_billing_threshold: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -296,6 +296,7 @@ class Quotation(SellingController):
# update enquiry status
self.update_opportunity("Quotation")
self.update_lead()
self.carry_forward_communication()
def on_cancel(self):
if self.lost_reasons:
@@ -307,6 +308,18 @@ class Quotation(SellingController):
self.update_opportunity("Open")
self.update_lead()
def carry_forward_communication(self):
from erpnext.crm.utils import copy_comments, link_communications
if not (
self.opportunity
and frappe.get_single_value("CRM Settings", "carry_forward_communication_and_comments")
):
return
copy_comments("Opportunity", self.opportunity, self)
link_communications("Opportunity", self.opportunity, self)
def print_other_charges(self, docname):
print_lst = []
for d in self.get("taxes"):

View File

@@ -5,7 +5,7 @@
import json
import frappe
from frappe.query_builder import DocType, Order
from frappe.query_builder import Criterion, DocType, Order
from frappe.utils import cint, get_datetime
from frappe.utils.nestedset import get_root_of
@@ -148,50 +148,55 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
if not frappe.db.exists("Item Group", item_group):
item_group = get_root_of("Item Group")
condition = get_conditions(search_term)
condition += get_item_group_condition(pos_profile)
lft, rgt = frappe.db.get_value("Item Group", item_group, ["lft", "rgt"])
bin_join_selection, bin_join_condition = "", ""
if hide_unavailable_items:
bin_join_selection = "LEFT JOIN `tabBin` bin ON bin.item_code = item.name"
bin_join_condition = "AND (item.is_stock_item = 0 OR (item.is_stock_item = 1 AND bin.warehouse = %(warehouse)s AND bin.actual_qty > 0))"
item = frappe.qb.DocType("Item")
item_group_dt = frappe.qb.DocType("Item Group")
items_data = frappe.db.sql(
"""
SELECT
item.name AS item_code,
item_group_subquery = (
frappe.qb.from_(item_group_dt)
.select(item_group_dt.name)
.where((item_group_dt.lft >= lft) & (item_group_dt.rgt <= rgt))
)
query = (
frappe.qb.from_(item)
.select(
item.name.as_("item_code"),
item.item_name,
item.description,
item.stock_uom,
item.image AS item_image,
item.image.as_("item_image"),
item.is_stock_item,
item.sales_uom
FROM
`tabItem` item {bin_join_selection}
WHERE
item.disabled = 0
AND item.has_variants = 0
AND item.is_sales_item = 1
AND item.is_fixed_asset = 0
AND item.item_group in (SELECT name FROM `tabItem Group` WHERE lft >= {lft} AND rgt <= {rgt})
AND {condition}
{bin_join_condition}
ORDER BY
item.name asc
LIMIT
{page_length} offset {start}""".format(
start=cint(start),
page_length=cint(page_length),
lft=cint(lft),
rgt=cint(rgt),
condition=condition,
bin_join_selection=bin_join_selection,
bin_join_condition=bin_join_condition,
),
{"warehouse": warehouse},
as_dict=1,
item.sales_uom,
)
.where(
(item.disabled == 0)
& (item.has_variants == 0)
& (item.is_sales_item == 1)
& (item.is_fixed_asset == 0)
& (item.item_group.isin(item_group_subquery))
& get_conditions(search_term, item)
)
)
item_group_condition = get_item_group_condition(pos_profile, item)
if item_group_condition is not None:
query = query.where(item_group_condition)
if hide_unavailable_items:
bin_dt = frappe.qb.DocType("Bin")
query = (
query.left_join(bin_dt)
.on(bin_dt.item_code == item.name)
.where(
(item.is_stock_item == 0)
| ((item.is_stock_item == 1) & (bin_dt.warehouse == warehouse) & (bin_dt.actual_qty > 0))
)
)
items_data = (
query.orderby(item.name, order=Order.asc).limit(cint(page_length)).offset(cint(start)).run(as_dict=1)
)
# return (empty) list if there are no results
@@ -262,56 +267,63 @@ def search_for_serial_or_batch_or_barcode_number(search_value: str) -> dict[str,
return scan_barcode(search_value)
def get_conditions(search_term):
condition = "("
condition += """item.name like {search_term}
or item.item_name like {search_term}""".format(search_term=frappe.db.escape("%" + search_term + "%"))
condition += add_search_fields_condition(search_term)
condition += ")"
def get_conditions(search_term, item=None):
if item is None:
item = frappe.qb.DocType("Item")
return condition
pattern = f"%{search_term}%"
conditions = [item.name.like(pattern), item.item_name.like(pattern)]
conditions += add_search_fields_condition(search_term, item)
return Criterion.any(conditions)
def add_search_fields_condition(search_term):
condition = ""
def add_search_fields_condition(search_term, item=None):
if item is None:
item = frappe.qb.DocType("Item")
pattern = f"%{search_term}%"
conditions = []
search_fields = frappe.get_all("POS Search Fields", fields=["fieldname"])
if search_fields:
for field in search_fields:
if not field.get("fieldname"):
continue
condition += " or item.`{}` like {}".format(
field["fieldname"], frappe.db.escape("%" + search_term + "%")
)
return condition
for field in search_fields:
if not field.get("fieldname"):
continue
conditions.append(item[field["fieldname"]].like(pattern))
return conditions
def get_item_group_condition(pos_profile):
cond = "and 1=1"
def get_item_group_condition(pos_profile, item=None):
if item is None:
item = frappe.qb.DocType("Item")
item_groups = get_item_groups(pos_profile)
if item_groups:
cond = "and item.item_group in (%s)" % (", ".join(["%s"] * len(item_groups)))
return item.item_group.isin(item_groups)
return cond % tuple(item_groups)
return None
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def item_group_query(doctype, txt, searchfield, start, page_len, filters):
item_groups = []
cond = "1=1"
pos_profile = filters.get("pos_profile")
item_filters = [["name", "like", f"%{txt}%"]]
if pos_profile:
item_groups = get_item_groups(pos_profile)
if item_groups:
cond = "name in (%s)" % (", ".join(["%s"] * len(item_groups)))
cond = cond % tuple(item_groups)
item_filters.append(["name", "in", item_groups])
return frappe.db.sql(
f""" select distinct name from `tabItem Group`
where {cond} and (name like %(txt)s) limit {page_len} offset {start}""",
{"txt": "%%%s%%" % txt},
return frappe.get_all(
"Item Group",
filters=item_filters,
fields=["name"],
distinct=True,
order_by="", # original raw SQL had no ORDER BY; suppress the injected default (creation desc on MariaDB)
limit_start=start,
limit_page_length=page_len,
as_list=True,
)

View File

@@ -0,0 +1,137 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import random_string
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.selling.page.point_of_sale.point_of_sale import get_items
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestPointOfSaleGetItems(ERPNextTestSuite):
"""Covers the raw-SQL -> frappe.qb conversion of point_of_sale.get_items."""
def setUp(self):
super().setUp()
# Reuse the bootstrap leaf item group; an item assigned directly to it
# falls inside its own (lft, rgt) subtree, which is what get_items filters on.
self.item_group = "_Test Item Group"
# A non-stock sales item keeps get_stock_availability cheap (no Bin needed)
# and keeps the item out of the hide_unavailable_items branch.
self.item_code = "_Test POS Item " + random_string(10)
item = frappe.get_doc(
{
"doctype": "Item",
"item_code": self.item_code,
"item_name": self.item_code,
"item_group": self.item_group,
"stock_uom": "_Test UOM",
"is_stock_item": 0,
"is_sales_item": 1,
"is_fixed_asset": 0,
"has_variants": 0,
"disabled": 0,
}
)
item.insert()
self.item = item
# make_pos_profile builds "_Test POS Profile" (hide_unavailable_items unset,
# no item_groups restriction). Rolled back by tearDown.
self.pos_profile = make_pos_profile().name
def _get_item_codes(self, search_term):
result = get_items(
start=0,
page_length=100,
price_list="Standard Selling",
item_group=self.item_group,
pos_profile=self.pos_profile,
search_term=search_term,
)
# get_items returns {"items": [...]} when the qb query yields rows,
# and a bare (empty) list when nothing matches.
items = result["items"] if isinstance(result, dict) else result
return [row.get("item_code") for row in items]
def _make_stock_item(self):
# Fresh stock item in the filtered item group so it passes the
# item_group.isin(subquery) clause and reaches the Bin left-join.
item_code = "_Test POS Stock Item " + random_string(10)
frappe.get_doc(
{
"doctype": "Item",
"item_code": item_code,
"item_name": item_code,
"item_group": self.item_group,
"stock_uom": "_Test UOM",
"is_stock_item": 1,
"is_sales_item": 1,
"is_fixed_asset": 0,
"has_variants": 0,
"disabled": 0,
}
).insert()
return item_code
def test_matching_search_term_returns_item(self):
# search_term matches Item.name / Item.item_name via the LIKE OR-condition;
# scan_barcode finds nothing for this value, so the converted qb query runs.
item_codes = self._get_item_codes(self.item_code)
self.assertIn(self.item_code, item_codes)
def test_non_matching_search_term_excludes_item(self):
non_matching = "zzz_no_such_item_" + random_string(10)
item_codes = self._get_item_codes(non_matching)
self.assertNotIn(self.item_code, item_codes)
def test_partial_search_term_matches_on_item_name(self):
# A substring of the item code must still match (LIKE %term%),
# proving the OR/LIKE clause survived the SQL->qb conversion.
partial = self.item_code.split(" ")[-1]
item_codes = self._get_item_codes(partial)
self.assertIn(self.item_code, item_codes)
def test_disabled_item_is_excluded(self):
# disabled == 0 is part of the converted WHERE clause; flipping it
# must drop the item even when the search term matches.
frappe.db.set_value("Item", self.item_code, "disabled", 1)
item_codes = self._get_item_codes(self.item_code)
self.assertNotIn(self.item_code, item_codes)
def test_non_sales_item_is_excluded(self):
# is_sales_item == 1 is part of the converted WHERE clause.
frappe.db.set_value("Item", self.item_code, "is_sales_item", 0)
item_codes = self._get_item_codes(self.item_code)
self.assertNotIn(self.item_code, item_codes)
def test_hide_unavailable_items_filters_on_bin_actual_qty(self):
# Covers the hide_unavailable_items branch: the Bin left-join only keeps a
# stock item when bin.warehouse == profile warehouse AND bin.actual_qty > 0.
# A second stock item with no Bin row (no stock) must be hidden.
warehouse = frappe.db.get_value("POS Profile", self.pos_profile, "warehouse")
frappe.db.set_value("POS Profile", self.pos_profile, "hide_unavailable_items", 1)
in_stock_item = self._make_stock_item()
out_of_stock_item = self._make_stock_item()
# Material Receipt gives in_stock_item actual_qty > 0 in the profile warehouse;
# out_of_stock_item gets no Bin row at all.
make_stock_entry(item_code=in_stock_item, target=warehouse, qty=5, basic_rate=100)
# Sanity-check the precondition the branch keys off of.
self.assertGreater(
frappe.db.get_value("Bin", {"item_code": in_stock_item, "warehouse": warehouse}, "actual_qty")
or 0,
0,
)
self.assertFalse(frappe.db.exists("Bin", {"item_code": out_of_stock_item}))
in_stock_codes = self._get_item_codes(in_stock_item)
self.assertIn(in_stock_item, in_stock_codes)
out_of_stock_codes = self._get_item_codes(out_of_stock_item)
self.assertNotIn(out_of_stock_item, out_of_stock_codes)

View File

@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe import _
from erpnext.controllers.trends import get_columns, get_data
@@ -40,9 +39,15 @@ def get_chart_data(data, conditions, filters):
labels = [column.split(":")[0] for column in columns]
datapoints = [0] * len(labels)
group_by_col_idx = None
if filters.get("group_by"):
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
for row in data:
# If group by filter, don't add first row of group (it's already summed)
if not row[start]:
# Skip the final grand-total row
if row[0] == f"'{_('Total')}'":
continue
if group_by_col_idx is not None and row[group_by_col_idx] == "":
continue
# Remove None values and compute only periodic data
row = [x if x else 0 for x in row[start:-2]]
@@ -59,4 +64,6 @@ def get_chart_data(data, conditions, filters):
"type": "line",
"lineOptions": {"regionFill": 1},
"fieldtype": "Currency",
"options": "currency",
"currency": conditions.get("company_currency"),
}

View File

@@ -39,9 +39,15 @@ def get_chart_data(data, conditions, filters):
labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns]
datapoints = [0] * len(labels)
group_by_col_idx = None
if filters.get("group_by"):
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
for row in data:
# If group by filter, don't add first row of group (it's already summed)
if not row[start]:
# Skip the final grand-total row
if row[0] == f"'{_('Total')}'":
continue
if group_by_col_idx is not None and row[group_by_col_idx] == "":
continue
# Remove None values and compute only periodic data
row = [x if x else 0 for x in row[start:-2]]
@@ -58,4 +64,6 @@ def get_chart_data(data, conditions, filters):
"type": "line",
"lineOptions": {"regionFill": 1},
"fieldtype": "Currency",
"options": "currency",
"currency": conditions.get("company_currency"),
}

View File

@@ -69,6 +69,17 @@ frappe.ui.form.on("Company", {
},
};
});
["default_wip_warehouse", "default_fg_warehouse", "default_scrap_warehouse"].forEach((fieldname) => {
frm.set_query(fieldname, function (doc) {
return {
filters: {
company: doc.name,
is_group: 0,
},
};
});
});
},
company_name: function (frm) {
@@ -307,6 +318,8 @@ erpnext.company.setup_queries = function (frm) {
["default_advance_received_account", { root_type: "Liability", account_type: "Receivable" }],
["default_advance_paid_account", { root_type: "Asset", account_type: "Payable" }],
["service_expense_account", { root_type: "Expense" }],
["expenses_added_to_stock_account", { root_type: "Expense" }],
["expenses_added_to_stock_contra_account", { root_type: "Expense" }],
],
function (i, v) {
erpnext.company.set_custom_query(frm, v);

View File

@@ -117,6 +117,10 @@
"service_expense_account",
"column_break_ereg",
"purchase_expense_contra_account",
"stock_expense_section",
"expenses_added_to_stock_account",
"column_break_gthb",
"expenses_added_to_stock_contra_account",
"stock_tab",
"auto_accounting_for_stock_settings",
"enable_perpetual_inventory",
@@ -824,6 +828,27 @@
"fieldtype": "Tab Break",
"label": "Buying and Selling"
},
{
"fieldname": "stock_expense_section",
"fieldtype": "Section Break",
"label": "Stock Expense"
},
{
"fieldname": "expenses_added_to_stock_account",
"fieldtype": "Link",
"label": "Expenses Added To Stock Account",
"options": "Account"
},
{
"fieldname": "column_break_gthb",
"fieldtype": "Column Break"
},
{
"fieldname": "expenses_added_to_stock_contra_account",
"fieldtype": "Link",
"label": "Expenses Added To Stock Contra Account",
"options": "Account"
},
{
"fieldname": "stock_tab",
"fieldtype": "Tab Break",

View File

@@ -98,6 +98,8 @@ class Company(NestedSet):
exception_budget_approver_role: DF.Link | None
exchange_gain_loss_account: DF.Link | None
existing_company: DF.Link | None
expenses_added_to_stock_account: DF.Link | None
expenses_added_to_stock_contra_account: DF.Link | None
fax: DF.Data | None
is_group: DF.Check
lft: DF.Int

View File

@@ -132,7 +132,7 @@
{
"fieldname": "credit_limits",
"fieldtype": "Table",
"label": "Credit Limit",
"label": "Credit & Overdue Limits",
"options": "Customer Credit Limit"
}
],

View File

@@ -75,6 +75,7 @@ class DeprecatedSerialNoValuation:
| (table.serial_no.like("%\n" + serial_no))
| (table.serial_no.like("%\n" + serial_no + "\n%"))
)
& (table.item_code == self.sle.item_code)
& (table.company == self.sle.company)
& (table.warehouse == self.sle.warehouse)
& (table.serial_and_batch_bundle.isnull())

View File

@@ -296,7 +296,7 @@ def get_batches_by_oldest(item_code, warehouse):
"""Returns the oldest batch and qty for the given item_code and warehouse"""
batches = get_batch_qty(item_code=item_code, warehouse=warehouse)
batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches]
batches_dates.sort(key=lambda tup: tup[1])
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
return batches_dates

View File

@@ -175,7 +175,7 @@ class Bin(Document):
& (subcontract_order.docstatus == 1)
)
if subcontract_doctype == "Purchase Order"
else (subcontract_order.docstatus == 1)
else ((subcontract_order.status != "Closed") & (subcontract_order.docstatus == 1))
)
)
@@ -212,6 +212,7 @@ class Bin(Document):
else (
(Coalesce(se.subcontracting_order, "") != "")
& (subcontract_order.name == se.subcontracting_order)
& (subcontract_order.status != "Closed")
)
)
)

View File

@@ -620,7 +620,13 @@ $.extend(erpnext.item, {
};
});
let fields = ["purchase_expense_account", "purchase_expense_contra_account", "default_cogs_account"];
let fields = [
"purchase_expense_account",
"purchase_expense_contra_account",
"expenses_added_to_stock_account",
"expenses_added_to_stock_contra_account",
"default_cogs_account",
];
fields.forEach((field) => {
frm.set_query(field, "item_defaults", (doc, cdt, cdn) => {

View File

@@ -721,7 +721,7 @@
},
{
"default": "0",
"description": "Expense for this item will be recognized over a period of months. Eg: prepaid insurance or annual software license",
"description": "Income from this item will be recognized over a period of months instead of all at once. Eg: annual subscription paid upfront.",
"fieldname": "enable_deferred_revenue",
"fieldtype": "Check",
"label": "Enable Deferred Revenue"
@@ -734,7 +734,7 @@
},
{
"default": "0",
"description": "Income from this item will be recognized over a period of months instead of all at once. Eg: annual subscription paid upfront.",
"description": "Expense for this item will be recognized over a period of months. Eg: prepaid insurance or annual software license",
"fieldname": "enable_deferred_expense",
"fieldtype": "Check",
"label": "Enable Deferred Expense"
@@ -1093,7 +1093,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-07-05 23:24:45.734144",
"modified": "2026-07-28 18:58:43.328497",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -21,6 +21,8 @@
"column_break_cpif",
"purchase_expense_account",
"purchase_expense_contra_account",
"expenses_added_to_stock_account",
"expenses_added_to_stock_contra_account",
"selling_defaults",
"selling_cost_center",
"column_break_12",
@@ -45,6 +47,7 @@
{
"fieldname": "default_warehouse",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Default Warehouse",
"options": "Warehouse",
@@ -73,6 +76,7 @@
"fieldname": "buying_cost_center",
"fieldtype": "Link",
"label": "Default Buying Cost Center",
"ignore_user_permissions": 1,
"options": "Cost Center",
"show_description_on_click": 1
},
@@ -92,6 +96,7 @@
"fieldname": "expense_account",
"fieldtype": "Link",
"label": "Default Expense Account",
"ignore_user_permissions": 1,
"options": "Account",
"show_description_on_click": 1
},
@@ -144,6 +149,7 @@
"fieldname": "deferred_expense_account",
"fieldtype": "Link",
"label": "Deferred Expense Account",
"ignore_user_permissions": 1,
"options": "Account",
"show_description_on_click": 1
},
@@ -181,6 +187,7 @@
"description": "Account to record additional purchase expenses like freight or customs for this item",
"fieldname": "purchase_expense_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Purchase Expense Account",
"options": "Account",
"show_description_on_click": 1
@@ -189,14 +196,34 @@
"description": "Used to balance the books when recording extra purchase costs like freight or customs",
"fieldname": "purchase_expense_contra_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Purchase Expense Contra Account",
"options": "Account"
},
{
"description": "Account to track value added to stock via Stock Entry, Stock Reconciliation or Landed Cost Voucher",
"fieldname": "expenses_added_to_stock_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Expenses Added To Stock Account",
"options": "Account",
"show_description_on_click": 1
},
{
"description": "Used to balance the books when recording expenses added to stock",
"fieldname": "expenses_added_to_stock_contra_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Expenses Added To Stock Contra Account",
"options": "Account",
"show_description_on_click": 1
},
{
"description": "Stock account where inventory value for this item will be tracked",
"fieldname": "default_inventory_account",
"fieldtype": "Link",
"label": "Default Inventory Account",
"ignore_user_permissions": 1,
"options": "Account",
"show_description_on_click": 1
},
@@ -211,7 +238,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-04-27 01:49:01.396845",
"modified": "2026-07-28 15:39:44.848087",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item Default",

View File

@@ -26,6 +26,8 @@ class ItemDefault(Document):
deferred_expense_account: DF.Link | None
deferred_revenue_account: DF.Link | None
expense_account: DF.Link | None
expenses_added_to_stock_account: DF.Link | None
expenses_added_to_stock_contra_account: DF.Link | None
income_account: DF.Link | None
inventory_account_currency: DF.Link | None
parent: DF.Data
@@ -33,6 +35,7 @@ class ItemDefault(Document):
parenttype: DF.Data
purchase_expense_account: DF.Link | None
purchase_expense_contra_account: DF.Link | None
purchase_price_variance_account: DF.Link | None
selling_cost_center: DF.Link | None
# end: auto-generated types

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "hash",
"creation": "2013-03-07 11:42:59",
"doctype": "DocType",
@@ -18,6 +19,7 @@
"columns": 3,
"fieldname": "warehouse_group",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Check Availability in Warehouse",
"options": "Warehouse"
@@ -26,6 +28,7 @@
"columns": 2,
"fieldname": "warehouse",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Request for",
"options": "Warehouse",
@@ -59,7 +62,7 @@
"in_create": 1,
"istable": 1,
"links": [],
"modified": "2025-12-02 16:02:23.254963",
"modified": "2026-07-28 15:54:36.089238",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item Reorder",

View File

@@ -1381,6 +1381,9 @@ def create_delivery_wo_so(pick_list, target, target_doc=None):
target_doc.company = pick_list.company
if not target_doc.customer:
target_doc.customer = pick_list.customer
item_table_mapper_without_so = {
"doctype": f"{target} Item",
"field_map": {

View File

@@ -652,6 +652,14 @@ class PurchaseReceipt(BuyingController):
item=item,
)
def make_expenses_added_to_stock_entries(item):
if not self.book_stock_expense_enabled():
return
amount = flt(item.landed_cost_voucher_amount, item.precision("base_net_amount"))
if amount and not item.is_fixed_asset:
self.append_expenses_added_to_stock_pair(gl_entries, item.item_code, amount, item)
def make_amount_difference_entry(item):
if item.amount_difference_with_purchase_invoice and stock_asset_rbnb:
account_currency = get_account_currency(stock_asset_rbnb)
@@ -796,6 +804,7 @@ class PurchaseReceipt(BuyingController):
make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
outgoing_amount = make_stock_received_but_not_billed_entry(d)
make_landed_cost_gl_entries(d)
make_expenses_added_to_stock_entries(d)
make_amount_difference_entry(d)
make_sub_contracting_gl_entries(d)
make_divisional_loss_gl_entry(d, outgoing_amount)

View File

@@ -5125,6 +5125,14 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(srbnb_cost, 1000)
def test_purchase_expense_account(self):
# Single, so it outlives this test - every later Purchase Receipt / Invoice would otherwise
# be forced to resolve the expense account pair and throw for unconfigured companies.
previous = frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")
self.addCleanup(
frappe.db.set_single_value, "Accounts Settings", "book_stock_expense_gl_entries", previous
)
frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 1)
item = "Test Item with Purchase Expense Account"
make_item(item, {"is_stock_item": 1})
company = "_Test Company with perpetual inventory"

View File

@@ -242,7 +242,7 @@ class RepostItemValuation(Document):
def clear_attachment(self):
if attachments := get_attachments(self.doctype, self.name):
attachment = attachments[0]
frappe.delete_doc("File", attachment.name, ignore_permissions=True)
frappe.delete_doc("File", attachment.name, ignore_permissions=True, force=True)
if self.reposting_data_file:
self.db_set("reposting_data_file", None)

View File

@@ -515,6 +515,34 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
)
)
def test_clear_attachment_skips_referenced_data_file(self):
riv = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Item and Warehouse",
"company": "_Test Company",
"item_code": "_Test Item",
"warehouse": "_Test Warehouse - _TC",
"posting_date": today(),
}
).insert(ignore_permissions=True)
attached = frappe.get_doc(
{
"doctype": "File",
"file_name": "repost_data.json.gz",
"content": "test",
"attached_to_doctype": riv.doctype,
"attached_to_name": riv.name,
}
).insert(ignore_permissions=True)
riv.db_set("reposting_data_file", attached.file_url)
riv.clear_attachment()
self.assertFalse(frappe.db.exists("File", attached.name))
self.assertIsNone(frappe.db.get_value("Repost Item Valuation", riv.name, "reposting_data_file"))
@ERPNextTestSuite.change_settings(
"Stock Reposting Settings",
{"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2},

View File

@@ -569,8 +569,6 @@ frappe.ui.form.on("Stock Entry", {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
}
frm.events.set_route_options_for_new_doc(frm);
frm.set_df_property(
"items",
"cannot_add_rows",
@@ -583,28 +581,6 @@ frappe.ui.form.on("Stock Entry", {
);
},
set_route_options_for_new_doc(frm) {
let batch_no_field = frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = function (row) {
return {
item: row.doc.item_code,
};
};
}
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
voucher_type: frm.doc.doctype,
warehouse: row.doc.s_warehouse || row.doc.t_warehouse,
};
};
}
},
get_items_from_transit_entry: function (frm) {
if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) {
frm.add_custom_button(
@@ -1312,6 +1288,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
}
onload_post_render() {
super.onload_post_render();
var me = this;
if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) {
me.company();

View File

@@ -176,6 +176,8 @@ class StockEntry(StockController, SubcontractingInwardController):
work_order: DF.Link | None
# end: auto-generated types
book_expenses_added_to_stock = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.status_updater = [

View File

@@ -1344,6 +1344,47 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin):
# receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
self.assertEqual(qty_after(receipt2), 10)
def test_cancel_shifts_same_timestamp_delivery_notes(self):
item = make_item().name
warehouse = "_Test Warehouse - _TC"
posting_date = today()
posting_time = "10:00:00"
make_stock_entry(
item_code=item,
to_warehouse=warehouse,
qty=100,
rate=10,
posting_date=posting_date,
posting_time="09:00:00",
)
dns = []
for i in range(5):
dns.append(
create_delivery_note(
item_code=item,
warehouse=warehouse,
qty=20,
rate=10 * i,
posting_date=posting_date,
posting_time=posting_time,
)
)
time.sleep(1)
dn = dns[2]
dn.cancel()
expected_qty_after_transaction_of_dns3 = 40
qty_after_transaction_of_dns3 = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": dns[3].name, "is_cancelled": 0},
"qty_after_transaction",
)
self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3)
def test_get_next_stock_reco_respects_creation_order(self):
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
# "next" reco when it was created after that entry. A reco created before it actually precedes

View File

@@ -46,17 +46,6 @@ frappe.ui.form.on("Stock Reconciliation", {
};
});
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
warehouse: row.doc.warehouse,
voucher_type: frm.doc.doctype,
};
};
}
if (frm.doc.company) {
erpnext.queries.setup_queries(frm, "Warehouse", function () {
return erpnext.queries.warehouse(frm.doc);

View File

@@ -57,6 +57,8 @@ class StockReconciliation(StockController):
set_warehouse: DF.Link | None
# end: auto-generated types
book_expenses_added_to_stock = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.head_row = ["Item Code", "Warehouse", "Quantity", "Valuation Rate"]

View File

@@ -84,6 +84,7 @@ def get_item_details(ctx, doc=None, for_validate=False, overwrite_warehouse=True
for_validate = parse_json(for_validate)
overwrite_warehouse = parse_json(overwrite_warehouse)
item = frappe.get_cached_doc("Item", ctx.item_code)
item.check_permission()
validate_item_details(ctx, item)
if isinstance(doc, str):

View File

@@ -14,12 +14,12 @@ def execute(filters=None):
conditions = get_columns(filters, "Delivery Note")
data = get_data(filters, conditions)
chart_data = get_chart_data(data, filters)
chart_data = get_chart_data(data, conditions, filters)
return conditions["columns"], data, None, chart_data
def get_chart_data(data, filters):
def get_chart_data(data, conditions, filters):
def wrap_in_quotes(label):
return f"'{label}'"
@@ -52,4 +52,6 @@ def get_chart_data(data, filters):
},
"type": "bar",
"fieldtype": "Currency",
"options": "currency",
"currency": conditions.get("company_currency"),
}

View File

@@ -4,6 +4,8 @@
import frappe
from frappe import _
import erpnext
def execute(filters: dict | None = None):
columns = get_columns()
@@ -24,6 +26,14 @@ def get_columns() -> list[dict]:
"label": _("Total Landed Cost"),
"fieldname": "landed_cost",
"fieldtype": "Currency",
"options": "currency",
},
{
"label": _("Currency"),
"fieldname": "currency",
"fieldtype": "Link",
"options": "Currency",
"hidden": 1,
},
{
"label": _("Purchase Voucher Type"),
@@ -49,6 +59,7 @@ def get_columns() -> list[dict]:
def get_data(filters) -> list[list]:
company_currency = erpnext.get_company_currency(filters.get("company"))
landed_cost_vouchers = get_landed_cost_vouchers(filters) or {}
landed_vouchers = list(landed_cost_vouchers.keys())
vendor_invoices = {}
@@ -57,7 +68,6 @@ def get_data(filters) -> list[list]:
data = []
print(vendor_invoices)
for name, vouchers in landed_cost_vouchers.items():
res = {
"name": name,
@@ -72,6 +82,7 @@ def get_data(filters) -> list[list]:
"landed_cost": d.landed_cost,
"voucher_type": d.voucher_type,
"voucher_no": d.voucher_no,
"currency": company_currency,
}
)
else:
@@ -88,7 +99,6 @@ def get_data(filters) -> list[list]:
if vendor_invoice_list and len(vendor_invoice_list) > len(vouchers):
for row in vendor_invoice_list[last_index + 1 :]:
print(row)
data.append({"vendor_invoice": row})
return data

View File

@@ -14,12 +14,12 @@ def execute(filters=None):
conditions = get_columns(filters, "Purchase Receipt")
data = get_data(filters, conditions)
chart_data = get_chart_data(data, filters)
chart_data = get_chart_data(data, conditions, filters)
return conditions["columns"], data, None, chart_data
def get_chart_data(data, filters):
def get_chart_data(data, conditions, filters):
def wrap_in_quotes(label):
return f"'{label}'"
@@ -53,4 +53,6 @@ def get_chart_data(data, filters):
"type": "bar",
"colors": ["#5e64ff"],
"fieldtype": "Currency",
"options": "currency",
"currency": conditions.get("company_currency"),
}

View File

@@ -325,6 +325,7 @@ class FIFOSlots:
del stock_ledger_entries
self._recompute_moving_average_slots()
self._rebalance_batch_slots()
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
@@ -346,6 +347,29 @@ class FIFOSlots:
if is_qty_slot(slot):
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
def _rebalance_batch_slots(self) -> None:
for item_dict in self.item_details.values():
if item_dict.get("has_batch_no"):
self._rebalance_batch_slot_values(item_dict["fifo_queue"])
def _rebalance_batch_slot_values(self, fifo_queue: list) -> None:
"""A batch is one valuation pool, so per-slot value differences are stale
detail: spread the pool value over its slots in proportion to qty."""
groups = {}
for slot in fifo_queue:
if is_batch_slot(slot):
key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None
groups.setdefault(key, []).append(slot)
for slots in groups.values():
total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots)
if total_qty <= 0:
continue
rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty
for slot in slots:
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
if stock_ledger_entries is not None:
return frappe._dict({}), frappe._dict({})

View File

@@ -5,7 +5,13 @@ from unittest.mock import patch
import frappe
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age
from erpnext.stock.report.stock_ageing.stock_ageing import (
BATCH_SLOT_QTY_INDEX,
BATCH_SLOT_VALUE_INDEX,
FIFOSlots,
format_report_data,
get_average_age,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -565,10 +571,11 @@ class TestStockAgeing(ERPNextTestSuite):
],
)
def test_partial_batch_reco_keeps_existing_slot_values(self):
def test_partial_batch_reco_pools_slot_values(self):
"""Ledger (same wh, batch B): [+10 @ 100, single-SLE reco >> 12]
The reco entry qty (delta 2) does not cover the whole batch, so
stock_value_difference / qty is not the batch rate: skip the rescale."""
stock_value_difference / qty is not the batch rate: skip the rescale.
The batch total (1400) is untouched, then pooled across both slots."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
@@ -608,11 +615,163 @@ class TestStockAgeing(ERPNextTestSuite):
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
[slot[:4] for slot in queue],
[
[batch_no, 1, 10.0, "2021-12-01"],
[batch_no, 1, 2.0, "2021-12-01"],
],
)
self.assertAlmostEqual(queue[0][4], 1166.67, places=2)
self.assertAlmostEqual(queue[1][4], 233.33, places=2)
def test_batch_receipts_at_differing_rates_pool_slot_values(self):
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10] and no issue.
Nothing goes negative, but the batch is one valuation pool, so both
age slots carry the pooled rate instead of their receipt value."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Pool Split",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-POOL-SPLIT-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "001", 10, 10, 0),
make_sle("2021-12-02", "002", 10, 20, 100),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 10.0, "2021-12-01", 1000.0],
[batch_no, 1, 2.0, "2021-12-01", 400.0],
[batch_no, 1, 10.0, "2021-12-01", 50.0],
[batch_no, 1, 10.0, "2021-12-01", 50.0],
],
)
def test_batch_pooling_preserves_total_on_repeating_rate(self):
"""Ledger (same wh, batch B): [+3 @ 100/3, +6 @ 0, +2 @ 0]
The pooled rate does not terminate, so assert the redistributed
slot values still add back to the batch total."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Pool Residual",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-POOL-RESIDUAL-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "001", 3, 3, 100),
make_sle("2021-12-02", "002", 6, 9, 0),
make_sle("2021-12-03", "003", 2, 11, 0),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual([slot[BATCH_SLOT_QTY_INDEX] for slot in queue], [3.0, 6.0, 2.0])
self.assertEqual(sum(slot[BATCH_SLOT_VALUE_INDEX] for slot in queue), 100.0)
def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self):
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5]
Consuming the zero-valued head slot at the pooled rate drives it
negative; slot values are then rebalanced to the batch pool rate."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Pool Rebalance",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-POOL-REBALANCE-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "001", 10, 10, 0),
make_sle("2021-12-02", "002", 10, 20, 100),
make_sle("2021-12-03", "003", -4, 16, -20),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 6.0, "2021-12-01", 30.0],
[batch_no, 1, 10.0, "2021-12-01", 50.0],
],
)

View File

@@ -136,7 +136,7 @@ frappe.query_reports["Stock Balance"] = {
fieldname: "include_zero_stock_items",
label: __("Include Zero Stock Items"),
fieldtype: "Check",
default: 0,
default: 1,
},
{
fieldname: "show_dimension_wise_stock",

View File

@@ -497,7 +497,9 @@ class update_entries_after:
self.data = frappe._dict()
if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"):
if (not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle")) and not self.args.get(
"cancelled"
):
self.initialize_previous_data(self.args)
self.build()
@@ -805,10 +807,23 @@ class update_entries_after:
def process_sle_against_current_timestamp(self):
sl_entries = get_sle_against_current_voucher(self.args)
if self.args.get("cancelled") and sl_entries:
self.seed_previous_sle_for_cancellation(sl_entries[0])
for sle in sl_entries:
sle["timestamp"] = sle.posting_datetime
self.process_sle(sle)
def seed_previous_sle_for_cancellation(self, anchor_sle):
key = (anchor_sle.item_code, anchor_sle.warehouse)
if key in self.prev_sle_dict:
return
args = frappe._dict(anchor_sle)
args["sle_id"] = args.name
prev_sle = get_previous_sle_of_current_voucher(args)
if prev_sle:
self.prev_sle_dict[key] = prev_sle
def get_future_entries_to_fix(self):
# includes current entry!
args = self.data[self.args.warehouse].previous_sle or frappe._dict(

View File

@@ -0,0 +1,211 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.account.test_account import create_account
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
COMPANY = "_Test Company with perpetual inventory"
WAREHOUSE = "Stores - TCP1"
class TestExpensesAddedToStock(ERPNextTestSuite):
def setUp(self):
self.restore_stock_expense_settings()
self.eats_account = create_account(
account_name="Expenses Added To Stock",
parent_account="Expenses - TCP1",
company=COMPANY,
)
self.eats_contra_account = create_account(
account_name="Expenses Added To Stock Contra",
parent_account="Expenses - TCP1",
company=COMPANY,
)
self.purchase_expense_account = create_account(
account_name="Test Purchase Expense EATS",
parent_account="Expenses - TCP1",
company=COMPANY,
)
self.purchase_expense_contra_account = create_account(
account_name="Test Purchase Expense Contra EATS",
parent_account="Expenses - TCP1",
company=COMPANY,
)
frappe.db.set_value(
"Company",
COMPANY,
{
"expenses_added_to_stock_account": self.eats_account,
"expenses_added_to_stock_contra_account": self.eats_contra_account,
"purchase_expense_account": self.purchase_expense_account,
"purchase_expense_contra_account": self.purchase_expense_contra_account,
},
)
frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 1)
self.item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
def restore_stock_expense_settings(self):
"""These are a Single and Company fields, so they outlive the test. Left set, every later
Purchase Receipt / Invoice in the run has to resolve the expense account pair and throws
for any company that has none configured."""
account_fields = [
"expenses_added_to_stock_account",
"expenses_added_to_stock_contra_account",
"purchase_expense_account",
"purchase_expense_contra_account",
]
previous_accounts = frappe.db.get_value("Company", COMPANY, account_fields, as_dict=True)
previous_flag = frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")
self.addCleanup(frappe.db.set_value, "Company", COMPANY, dict(previous_accounts))
self.addCleanup(
frappe.db.set_single_value,
"Accounts Settings",
"book_stock_expense_gl_entries",
previous_flag,
)
def get_gl_balances(self, voucher_type, voucher_no):
entries = frappe.get_all(
"GL Entry",
filters={
"voucher_type": voucher_type,
"voucher_no": voucher_no,
"is_cancelled": 0,
"account": ("in", [self.eats_account, self.eats_contra_account]),
},
fields=["account", "debit", "credit"],
)
balances = frappe._dict({self.eats_account: 0.0, self.eats_contra_account: 0.0})
debits = frappe._dict({self.eats_account: 0.0, self.eats_contra_account: 0.0})
credits = frappe._dict({self.eats_account: 0.0, self.eats_contra_account: 0.0})
for entry in entries:
balances[entry.account] += entry.debit - entry.credit
debits[entry.account] += entry.debit
credits[entry.account] += entry.credit
return balances, debits, credits
def test_material_receipt_books_expenses_added_to_stock(self):
se = make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
_balances, debits, credits = self.get_gl_balances("Stock Entry", se.name)
self.assertEqual(debits[self.eats_account], 1000)
self.assertEqual(credits[self.eats_contra_account], 1000)
def test_material_issue_books_reverse_pair(self):
make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
se = make_stock_entry(item_code=self.item, from_warehouse=WAREHOUSE, qty=5, company=COMPANY)
_balances, debits, credits = self.get_gl_balances("Stock Entry", se.name)
self.assertEqual(credits[self.eats_account], 500)
self.assertEqual(debits[self.eats_contra_account], 500)
def test_material_transfer_books_nothing(self):
make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
se = make_stock_entry(
item_code=self.item,
from_warehouse=WAREHOUSE,
to_warehouse="Finished Goods - TCP1",
qty=5,
company=COMPANY,
)
_balances, debits, credits = self.get_gl_balances("Stock Entry", se.name)
self.assertEqual(debits[self.eats_account], 0)
self.assertEqual(credits[self.eats_account], 0)
def test_stock_reconciliation_books_pair(self):
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
sr = create_stock_reconciliation(
item_code=self.item, warehouse=WAREHOUSE, qty=15, rate=100, company=COMPANY
)
_balances, debits, credits = self.get_gl_balances("Stock Reconciliation", sr.name)
self.assertEqual(debits[self.eats_account], 500)
self.assertEqual(credits[self.eats_contra_account], 500)
def test_landed_cost_voucher_books_pair(self):
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
pr = make_purchase_receipt(
company=COMPANY, warehouse=WAREHOUSE, item_code=self.item, qty=10, rate=100
)
_balances, debits, credits = self.get_gl_balances("Purchase Receipt", pr.name)
self.assertEqual(debits[self.eats_account], 0)
create_landed_cost_voucher("Purchase Receipt", pr.name, COMPANY, charges=200)
_balances, debits, credits = self.get_gl_balances("Purchase Receipt", pr.name)
self.assertEqual(debits[self.eats_account], 200)
self.assertEqual(credits[self.eats_contra_account], 200)
def test_no_entries_when_feature_disabled(self):
frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 0)
se = make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
_balances, debits, credits = self.get_gl_balances("Stock Entry", se.name)
self.assertEqual(debits[self.eats_account], 0)
self.assertEqual(credits[self.eats_contra_account], 0)
def test_missing_contra_account_raises_when_feature_enabled(self):
frappe.db.set_value("Company", COMPANY, "expenses_added_to_stock_contra_account", None)
self.assertRaises(
frappe.ValidationError,
make_stock_entry,
item_code=self.item,
to_warehouse=WAREHOUSE,
qty=10,
rate=100,
company=COMPANY,
)
def test_service_item_books_nothing_on_purchase_invoice_with_update_stock(self):
"""A service item carries no stock value, so booking it produced a GL row with neither a
debit nor a credit, which GL Entry rejects outright."""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
service_item = make_item(properties={"is_stock_item": 0}).name
pi = make_purchase_invoice(
company=COMPANY,
warehouse=WAREHOUSE,
item_code=service_item,
qty=1,
rate=500,
update_stock=1,
expense_account="Cost of Goods Sold - TCP1",
cost_center="Main - TCP1",
)
self.assertEqual(pi.docstatus, 1)
_balances, debits, credits = self.get_gl_balances("Purchase Invoice", pi.name)
self.assertEqual(debits[self.eats_account], 0)
self.assertEqual(credits[self.eats_contra_account], 0)
booked = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Purchase Invoice",
"voucher_no": pi.name,
"is_cancelled": 0,
"account": self.purchase_expense_account,
},
)
self.assertFalse(booked)

View File

@@ -8,7 +8,6 @@
"document_type": "Document",
"engine": "InnoDB",
"field_order": [
"title",
"naming_series",
"sales_order",
"customer",
@@ -29,6 +28,7 @@
"service_items_section",
"service_items",
"tab_other_info",
"title",
"order_status_section",
"status",
"per_raw_material_received",
@@ -43,10 +43,8 @@
"fields": [
{
"allow_on_submit": 1,
"default": "{customer_name}",
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title",
"no_copy": 1,
"print_hide": 1
@@ -306,7 +304,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-02-26 17:16:21.697846",
"modified": "2026-07-27 11:20:14.512336",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order",

View File

@@ -8,7 +8,6 @@
"document_type": "Document",
"engine": "InnoDB",
"field_order": [
"title",
"naming_series",
"purchase_order",
"supplier",
@@ -55,6 +54,7 @@
"additional_costs",
"total_additional_costs",
"tab_other_info",
"title",
"order_status_section",
"status",
"column_break_39",
@@ -69,10 +69,8 @@
"fields": [
{
"allow_on_submit": 1,
"default": "{supplier_name}",
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title",
"no_copy": 1,
"print_hide": 1
@@ -494,7 +492,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2025-11-14 10:31:40.682892",
"modified": "2026-07-27 11:20:14.512336",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Order",

View File

@@ -336,6 +336,85 @@ class TestSubcontractingOrder(ERPNextTestSuite):
bin_after_cancel_sco.reserved_qty_for_sub_contract, bin_before_sco.reserved_qty_for_sub_contract
)
def test_close_subcontracting_order_releases_reserved_qty(self):
# RM in stock at the reserve warehouse for transfer
make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=10, basic_rate=100)
make_stock_entry(
target="_Test Warehouse - _TC", item_code="_Test Item Home Desktop 100", qty=20, basic_rate=100
)
bin_before_sco = frappe.db.get_value(
"Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
fieldname="reserved_qty_for_sub_contract",
as_dict=1,
)
# Create SCO with a reserve warehouse on the supplied items
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 1",
"qty": 10,
"rate": 100,
"fg_item": "_Test FG Item",
"fg_item_qty": 10,
},
]
sco = get_subcontracting_order(service_items=service_items)
# Transfer only 90% of the raw materials to the supplier warehouse
ste = frappe.get_doc(make_rm_stock_entry(sco.name))
for item in ste.items:
item.qty *= 0.9
ste.save()
ste.submit()
sco.load_from_db()
self.assertEqual(sco.status, "Partial Material Transferred")
# Receive only a partial qty so the order stays open (per_received < 100)
scr = make_subcontracting_receipt(sco.name)
scr.items[0].qty -= 1
scr.save()
scr.submit()
sco.load_from_db()
self.assertEqual(sco.status, "Partially Received")
# Keep another SCO open so transfers from the closed SCO must not reduce its reservation
open_sco = get_subcontracting_order(service_items=service_items)
self.assertEqual(open_sco.status, "Open")
bin_before_close = frappe.db.get_value(
"Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
fieldname=["reserved_qty_for_sub_contract", "projected_qty"],
as_dict=1,
)
# One unit remains reserved for the partially transferred SCO, plus ten for the open SCO
self.assertEqual(
bin_before_close.reserved_qty_for_sub_contract,
bin_before_sco.reserved_qty_for_sub_contract + 11,
)
# Close the partially-received order
sco.update_status("Closed")
self.assertEqual(sco.status, "Closed")
bin_after_close = frappe.db.get_value(
"Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
fieldname=["reserved_qty_for_sub_contract", "projected_qty"],
as_dict=1,
)
# Closing releases the remaining unit without applying its transfer against the open SCO
self.assertEqual(
bin_after_close.reserved_qty_for_sub_contract,
bin_before_sco.reserved_qty_for_sub_contract + 10,
)
self.assertEqual(bin_after_close.projected_qty, bin_before_close.projected_qty + 1)
def test_send_to_subcontractor_ste_submit_without_sco_write_permission(self):
"""A Stock-only user (can submit Stock Entries but has no Subcontracting Order write) must be
able to submit and cancel a 'Send to Subcontractor' Stock Entry. The SCO status update on the