Compare commits

...

63 Commits

Author SHA1 Message Date
Sudharsanan Ashok
36f88d0f1b fix(stock): handle multi-item opening balance in Stock Ledger report (#57591)
* fix(stock): handle multi-item opening balance in Stock

* test(stock): add unit test for multi-item Stock Ledger report

---------

Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
(cherry picked from commit 0dbe410414)

# Conflicts:
#	erpnext/stock/report/stock_ledger/stock_ledger.py
#	erpnext/stock/report/stock_ledger/test_stock_ledger_report.py
2026-08-04 16:45:46 +00:00
Mihir Kandoi
3aed38423e Merge pull request #57787 from frappe/mergify/bp/version-15-hotfix/pr-57757
fix(opportunity): add validation for positive item quantities (backport #57757)
2026-08-04 17:26:58 +05:30
Mihir Kandoi
33446f4f4a Merge pull request #57784 from frappe/mergify/bp/version-15-hotfix/pr-57772
fix(accounts): skip party dashboard without invoice permission (backport #57772)
2026-08-04 16:59:39 +05:30
R-Jayaraman
7ef039f6ed chore: use flt() in qty check
(cherry picked from commit 69de8f2d62)
2026-08-04 11:23:36 +00:00
R-Jayaraman
f47346aa90 fix(opportunity): add validation for positive item quantities
(cherry picked from commit c47cc37441)
2026-08-04 11:23:35 +00:00
Sudharsanan11
9d417da3d8 fix(accounts): skip party dashboard without invoice permission
(cherry picked from commit ed78dd37be)
2026-08-04 11:04:04 +00:00
mergify[bot]
928f984198 fix: escape data in multiple templates (backport #57742) (#57769)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-04 09:56:46 +00:00
Deepesh Garg
78a43833a0 Merge pull request #57759 from frappe/mergify/bp/version-15-hotfix/pr-57296
fix: Ignore permission while deleting user permission (#57296)
2026-08-03 19:06:58 +05:30
Deepesh Garg
faa7c466b1 fix: Ignore permission while deleting user permission
(cherry picked from commit 3b10ff7df7)
2026-08-03 13:02:49 +00:00
mergify[bot]
ee4e296ce6 fix(accounts): fetch deferred invoice docs on non-empty sales_docs or purchase_docs in repost accounting ledger (backport #57753) (#57756)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-03 12:33:56 +00:00
Mihir Kandoi
0b08129b40 Merge pull request #57749 from frappe/mergify/bp/version-15-hotfix/pr-57747
fix: disabled item attribute blocks unrelated edits to existing variants (backport #57747)
2026-08-03 17:03:26 +05:30
Mihir Kandoi
c488de8f12 test(stock): isolate the disabled attribute fixtures
The test disabled the shared `Test Size` Item Attribute. On version-15
`FrappeTestCase` rolls back once per class instead of once per test, so the
flag stayed visible for the rest of `TestItem` and broke the seven tests that
build a variant from that attribute.

Build a dedicated attribute and template instead. Nothing the test writes is
reachable from another test, on either branch, so no cleanup is needed.
2026-08-03 16:43:45 +05:30
Mihir Kandoi
005b626482 test(stock): cover editing a variant whose attribute is disabled
Assert that a variant saves after its attribute is disabled when the edit
leaves the attribute rows alone, and that changing an attribute value still
throws.

(cherry picked from commit 8d5326196e)
2026-08-03 10:47:25 +00:00
Mihir Kandoi
2993747636 fix(stock): validate only the variant attributes that changed
Disabling an Item Attribute writes `disabled = 1` into every Item Variant
Attribute row, including the rows on the template. `validate_variant` runs
on every save and walks the whole attribute table, so any later save of an
existing variant re-checked its untouched rows against the now-disabled
template row and threw. `update_variants` hit the same wall, which made a
single template save fail once an attribute was disabled.

The flag exists to keep an attribute out of new variants, not to freeze the
variants that already use it. item.js only reads it to drop the attribute
from the variant creation dialog.

Skip rows that are unchanged since the last save. New and edited rows are
still checked, so a disabled attribute cannot be added to an existing
variant, and the same guard covers the sibling checks for attributes and
values that the template no longer offers.

(cherry picked from commit 25cd793617)
2026-08-03 10:47:24 +00:00
Mihir Kandoi
a9a3c20f3f Merge pull request #57729 from frappe/mergify/bp/version-15-hotfix/pr-57647
fix(sales): reject sales returns where every item has zero quantity (backport #57647)
2026-08-03 13:51:58 +05:30
Mihir Kandoi
aa20df88f5 Merge remote-tracking branch 'origin/version-15-hotfix' into bp15-57647
# Conflicts:
#	erpnext/controllers/sales_and_purchase_return.py
#	erpnext/controllers/tests/test_sales_and_purchase_return.py
2026-08-03 13:30:10 +05:30
Mihir Kandoi
5ec87ae06c chore: resolve conflict 2026-08-03 13:29:37 +05:30
Mihir Kandoi
b4e8aef2f8 Merge pull request #57721 from frappe/mergify/bp/version-15-hotfix/pr-57645
fix(purchase): reject purchase returns where every item has zero quan… (backport #57645)
2026-08-03 13:05:22 +05:30
R-Jayaraman
5152281618 test(sales): add coverage for zero-qty return rejection
Greptile flagged that the sales-side zero-qty-return fix had no dedicated
test proving the behavior - the existing suite happened to pass, but
nothing specifically asserted that an all-zero return is rejected while
a normal negative-qty return still succeeds.

Adds two tests covering the doctypes that rely entirely on this check
(no other guard covers them for a non-stock-effect return):
- Delivery Note return with qty 0 -> rejected
- Sales Invoice return with qty 0 (no update_stock) -> rejected

POS Invoice is not covered separately here since it always runs with
update_stock=1, which is already guarded by the pre-existing
validate_zero_qty_for_return_invoices_with_stock check regardless of
this fix.

(cherry picked from commit 732c884633)

# Conflicts:
#	erpnext/controllers/tests/test_sales_and_purchase_return.py
2026-08-03 07:34:25 +00:00
R-Jayaraman
48beb2ee23 fix(sales): reject sales returns where every item has zero quantity
validate_returned_items() set items_returned=True whenever a row matched
a valid item from the original document, even if its qty was 0. This let
a Sales Invoice, Delivery Note, or POS Invoice return be submitted with
every line at qty=0 - a no-op document with no stock or financial effect
that still consumed a document number and linked back to the original
transaction.

Scoped to the Sales side only: items_returned now flips to True for
Sales Invoice/Delivery Note/POS Invoice only when qty (or received_qty)
is actually negative, so an all-zero sales return correctly hits the
existing "At least one item should be entered with negative quantity"
check. Purchase Invoice, Purchase Receipt, and Subcontracting Receipt
are unchanged.

(cherry picked from commit a3e9d13da3)

# Conflicts:
#	erpnext/controllers/sales_and_purchase_return.py
2026-08-03 07:33:47 +00:00
Mihir Kandoi
198468aa5a test(purchase): fit the backported test to version-15-hotfix
Drop test_sales_return_validates_against_original: it came in with the new
file rather than with the change being backported, covers a raw-SQL to
query-builder conversion that only exists on develop, and imports
erpnext.stock.doctype.delivery_note.mapper, a module this branch does not
have. Base the remaining tests on FrappeTestCase, since ERPNextTestSuite
does not exist here either.
2026-08-03 12:42:14 +05:30
R-Jayaraman
070a7cfb91 test(purchase): add coverage for zero-qty return rejection
(cherry picked from commit cde2963da1)

# Conflicts:
#	erpnext/controllers/tests/test_sales_and_purchase_return.py
2026-08-03 06:16:38 +00:00
R-Jayaraman
8676add875 fix(purchase): reject purchase returns where every item has zero quantity
validate_returned_items() set items_returned=True whenever a row matched
a valid item from the original document, even if its qty was 0. This let
a Purchase Invoice, Purchase Receipt, or Subcontracting Receipt return be
submitted with every line at qty=0 - a no-op document with no stock or
financial effect that still consumed a document number and linked back
to the original transaction.

Scoped to the Purchase side only: items_returned now flips to True for
Purchase Invoice/Purchase Receipt/Subcontracting Receipt only when qty
(or received_qty) is actually negative, so an all-zero purchase return
correctly hits the existing "At least one item should be entered with
negative quantity" check. Sales Invoice, Delivery Note, and POS Invoice
are unchanged.

Also applies a corresponding check to the item_name-only fallback branch
(for rows without an item_code - Item Code is not mandatory on Purchase
Invoice Item), which previously bypassed this fix entirely and still set
items_returned=True unconditionally regardless of quantity. For that
branch specifically, only qty is checked (not received_qty): with no
linked Item there's no accepted/rejected split, so received_qty carries
no independent meaning and a qty=0 row must be rejected regardless of
its value.

(cherry picked from commit b63066ed44)
2026-08-03 06:16:37 +00:00
Mihir Kandoi
0f6c6d4df7 Merge pull request #57700 from frappe/mergify/bp/version-15-hotfix/pr-57699
fix: prevent duplicate shipping charges without cost center (backport #57699)
2026-08-02 12:45:29 +05:30
Mihir Kandoi
70da05edb7 fix: resolve version-15 backport conflict 2026-08-02 12:26:50 +05:30
Mihir Kandoi
724eb1aac4 Merge pull request #57696 from frappe/mergify/bp/version-15-hotfix/pr-57674
fix: preserve UOM conversion factor precision in transactions (backport #57674)
2026-08-02 12:23:29 +05:30
Mihir Kandoi
42a2674341 chore: remove shipping rule comments
(cherry picked from commit 106ecd7120)
2026-08-02 06:48:47 +00:00
Mihir Kandoi
a6dff3fc47 fix: prevent duplicate shipping charges without cost center
(cherry picked from commit a4134af30b)

# Conflicts:
#	erpnext/selling/doctype/sales_order/test_sales_order.py
2026-08-02 06:48:47 +00:00
Mihir Kandoi
00df8652e3 fix: resolve version 15 backport conflict 2026-08-02 12:03:47 +05:30
Mihir Kandoi
5a5e20e167 test: fractional conversion factor survives Material Request to Purchase Order
Fails before the fix with 0.45 != 0.453592292 on a site with Float
Precision 2, and 0.454 on the default of 3.

(cherry picked from commit f4d70c2d60)
2026-08-02 06:25:36 +00:00
Mihir Kandoi
4373e295de fix: preserve UOM conversion factor precision in transactions
calculate_item_values rounds every Float field on an item row to the
site's Float Precision (3 by default), and conversion_factor was one of
them. The factor is a ratio, not a rate: UOM Conversion Factor.value is
stored at precision 9, and Material Request keeps the full value because
it has no currency field and so never runs the calculation.

Mapping a Material Request to a Purchase Order therefore truncated the
factor - 0.453592292 for Pound -> Kg became 0.454 - and stock_qty, which
is recomputed as qty * conversion_factor, drifted from the quantity that
was requested, leaving the Material Request unable to close.

Exclude conversion_factor from the rounded fields on the server and on
the client. Factors below the site precision would otherwise round to
zero outright.

(cherry picked from commit 269cc6ee3b)

# Conflicts:
#	erpnext/controllers/taxes_and_totals.py
2026-08-02 06:25:36 +00:00
Diptanil Saha
8bef78afda Merge pull request #57691 from frappe/mergify/bp/version-15-hotfix/pr-57201
fix: permission checks on various whitelisted methods (backport #57201)
2026-08-01 15:45:53 +05:30
Mihir Kandoi
5b47607bc6 Merge pull request #57665 from frappe/mergify/bp/version-15-hotfix/pr-57658
fix: respect quantity precision in material transfer validation (backport #57658)
2026-08-01 15:34:27 +05:30
diptanilsaha
c38c9d5d9b fix(payment_request): added permission checks on resend_payment_email
(cherry picked from commit 0659bd7049)
2026-08-01 15:27:05 +05:30
diptanilsaha
9cd5997500 fix(item_variant): added permission checks on enqueue_multiple_variant_creation
(cherry picked from commit 3b0cbc972e)
2026-08-01 15:26:59 +05:30
diptanilsaha
338fff20db fix(assets): add permission checks on whitelisted methods on asset_capitalization
(cherry picked from commit 09d721d1be)
2026-08-01 15:25:28 +05:30
Sudharsanan11
f9381cc8f9 test: cover material transfer quantity precision
(cherry picked from commit 59bb56aa8d)
2026-08-01 14:37:32 +05:30
Sudharsanan11
a5ed3a5945 fix: respect quantity precision in material transfer validation
(cherry picked from commit eb969a5866)
2026-08-01 14:37:32 +05:30
mergify[bot]
cf42c52530 fix(quotation): carry forward communications from opportunity at after_insert (backport #57639) (#57642)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 20:56:17 +05:30
mergify[bot]
42d53783bb fix(plant_floor): add missing perm check on get_stock_summary (backport #57667) (#57669)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 15:26:15 +00:00
Mihir Kandoi
276d023946 Merge pull request #57654 from aerele/backport-57567-version-15-hotfix
fix: guard against None row in get_stock_balance_for (backport #57567)
2026-07-31 18:56:31 +05:30
pandiyan
72f293f131 fix: guard against None row in get_stock_balance_for (backport #57567)
get_stock_balance_for() takes row=None by default, but the batch-tracked
branch dereferenced it unconditionally while the two neighbouring row
accesses already guard. Calling it with a batch_no and no row raised
AttributeError: 'NoneType' object has no attribute 'use_serial_batch_fields'.

semgrep's missing-argument-type-hint rule matches the whole function body,
so touching any line inside it re-fingerprints the pre-existing untyped
arguments and reports them as introduced by this PR. Silenced with
nosemgrep instead of annotating: on a whitelisted method the hints are
enforced at runtime by pydantic, which is not a risk worth taking on v15.
2026-07-31 18:08:25 +05:30
mergify[bot]
b826b7c3e6 fix: use payment entry posting date for received amount exchange rate (backport #57660) (#57662)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 12:35:48 +00:00
mergify[bot]
b2918b8bb3 feat: make Shipping Rule Cost Center optional with company default fallback (backport #57355) (#57402)
feat: make Shipping Rule Cost Center optional with company default fallback (#57355)

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 <jatinsarna8@gmail.com>
2026-07-31 14:51:43 +05:30
Mihir Kandoi
f727f863bb Merge pull request #57020 from frappe/mergify/bp/version-15-hotfix/pr-57015
fix(stock): pick list serial batch posting date (backport #57015)
2026-07-31 13:40:10 +05:30
mergify[bot]
bb36a4fd08 feat: auto-fill subscription accounting dimensions from plan with item fallback (backport #57615) (#57621)
* feat: auto-fill subscription accounting dimensions from plan with item fallback (#57615)

When a plan is selected in the Subscription's Plans table, the Subscription's
accounting dimensions (cost center and any custom dimensions) auto-fill from the
plan, falling back to the plan item's company default (selling cost center for a
Customer, buying for a Supplier). Only empty fields are filled. Stale async
responses are ignored so a quick re-pick of the plan can't be overwritten.

(cherry picked from commit 7febc28ed6)

# Conflicts:
#	erpnext/accounts/doctype/subscription/subscription.js
#	erpnext/accounts/doctype/subscription/subscription.py
#	erpnext/accounts/doctype/subscription/test_subscription.py

* fix: resolve backport merge conflicts for #57615

---------

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
2026-07-31 12:38:58 +05:30
mergify[bot]
ebf5a462b3 fix: filter Accounts Receivable by invoice sales partner (backport #57628) (#57646)
fix: filter Accounts Receivable by invoice sales partner (#57628)

Filter Accounts Receivable and AR Summary on the Sales Invoice's own
sales_partner instead of the customer's default_sales_partner, and read
the Sales Partner column from the invoice. Returns are attributed to the
invoice they settle, matching how the Sales Person filter works.

(cherry picked from commit fd7765ac02)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-31 12:05:44 +05:30
Mihir Kandoi
bae02a6212 Merge pull request #57242 from frappe/mergify/bp/version-15-hotfix/pr-57223
fix(projects): include on hold status in project filters and reports (backport #57223)
2026-07-31 11:49:50 +05:30
Poovetha
54390bdeb9 fix(projects): add project filter
(cherry picked from commit 7248961568)
2026-07-31 11:09:53 +05:30
Poovetha
82850fb447 test(projects): add test to ensure on hold project retains status
(cherry picked from commit 79e5ccd370)
2026-07-31 11:09:53 +05:30
Poovetha
478426b436 fix(projects): include on hold status in project filters and reports
(cherry picked from commit 51a9fc0316)
2026-07-31 11:09:53 +05:30
ruthra kumar
83837868a0 Merge pull request #57640 from frappe/mergify/bp/version-15-hotfix/pr-57434
fix: update doc status in period closing voucher (backport #57434)
2026-07-31 10:57:12 +05:30
mergify[bot]
972a990b01 fix: do not fetch a random inventory account when multiple inventory accounts exist (backport #57626) (#57631)
* fix: do not fetch a random inventory account when multiple inventory accounts exist (#57626)

(cherry picked from commit 386a4ac1f0)

# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py

* chore: fix conflicts

Removed redundant test for valuation taxes in purchase receipt.

* fix: build warehouse account map only when perpetual inventory needs it

For asset purchase receipts or provisional accounting with perpetual
inventory disabled, GL entries do not use warehouse accounts. Building
the full warehouse account map in that case now throws when a company
has multiple inventory accounts and no default, breaking asset receipt
submission. Mirrors the gating on develop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: set default inventory account in valuation taxes LCV test

The conflict resolution kept the pre-backport copy of
test_valuation_taxes_lcv_repost_after_billing, which enables perpetual
inventory on _Test Company without configuring a default inventory
account. The test then failed on submit and leaked the perpetual
inventory flag, breaking every stock test that ran after it in the same
process. Restore the cherry-picked version from #57626.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:11:02 +00:00
nareshkannasln
310b9d4e65 fix: validate account frozen date
(cherry picked from commit b3c2ba5381)
2026-07-31 05:05:37 +00:00
mergify[bot]
68c24f3767 feat: status based bar colors in Work Order gantt view (backport #57634) (#57635)
feat: status based bar colors in Work Order gantt view (#57634)

(cherry picked from commit d59c5e36bc)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-31 09:59:07 +05:30
Mihir Kandoi
f2ec60b7a5 Merge pull request #57629 from frappe/mergify/bp/version-15-hotfix/pr-57616
fix: seed standard Item Groups under the existing tree root (backport #57616)
2026-07-30 19:46:00 +05:30
Mihir Kandoi
1602639a80 fix: resolve backport conflicts for version-15
install() holds the preset list inline on this branch, so the root is
resolved there instead of in get_preset_records. Dropping the preset-record
test with it -- there is no seam to call without running the whole installer.

The patch test is adapted to this branch: TestItem does not roll back between
tests, so it restores the original root name, and it passes parent_item_group
explicitly since ItemGroup.validate skips root-defaulting under
frappe.flags.in_test.
2026-07-30 19:22:29 +05:30
Mihir Kandoi
848335086c fix: seed standard Item Groups under the existing tree root
install_fixtures always inserted "All Item Groups" as a parentless group.
On a site where another app had already created the root, ItemGroup.validate
re-parented it, leaving a second group-root that held the standard groups
while the real root held everything else.

This is reproducible with the healthcare app on a non-English site: its
after_install seeds the root as _("All Item Groups"), so a pt-BR site gets
"Todos os Grupos de Itens" as the root before the setup wizard runs. The
split predates #57390 -- the old translated-name lookup resolved to the same
root and produced an identical tree.

Resolve the root once with get_root_of (falling back to the canonical English
name on fresh installs) and use it for the root record's exists-guard and the
standard groups' parent, matching Company.create_default_departments.

Patch merges an already-seeded "All Item Groups" into the root it sits under,
lifting its children and repointing every link.

Closes #57581

(cherry picked from commit e7088d8981)

# Conflicts:
#	erpnext/setup/doctype/item_group/test_item_group.py
#	erpnext/setup/setup_wizard/operations/install_fixtures.py
2026-07-30 13:41:45 +00:00
Shllokkk
9a596594da Merge pull request #57619 from Shllokkk/asset-manual-create-valuation-rate-v15
fix: source manually created asset value from valuation rate
2026-07-30 14:58:42 +05:30
Shllokkk
455d6d4ac1 fix: source manually created asset value from valuation rate 2026-07-30 14:31:54 +05:30
mergify[bot]
7cecff9fa4 fix: let Purchase Receipt cancel defer to Frappe's linked-document check (backport #57592) (#57602)
fix: let Purchase Receipt cancel defer to Frappe's linked-document check (#57592)

on_cancel pre-blocked cancellation with its own "Purchase Invoice is
already submitted" guard, duplicating the check Frappe already runs for any
submitted linked document. Drop the guard and the unused check_next_docstatus()
method it mirrored so the receipt defers to the framework: the Cancel All
Documents flow cancels the invoice first and then the receipt, and a direct
cancel is still rejected by Frappe's linked-document check.

Add a regression test that a direct cancel of a receipt with a submitted
invoice is rejected and rolls back, leaving no stray stock or GL entries.

(cherry picked from commit cfe18e8427)

# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
#	erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-30 12:41:53 +05:30
mergify[bot]
94d63ebb49 fix(stock): value batched packed-item returns from the original bundle (backport #57327) (#57510)
fix(stock): value batched packed-item returns from the original bundle  (#57327)

* fix(stock): value batched packed-item returns from the original bundle

when a return delivery note or sales invoice bundle is built via the
use_serial_batch_fields / sle-driven path, its voucher_detail_no keeps the
packed item instead of being remapped to the parent dn/si item. the return
valuation lookup then misses and the bundle values at zero, so the sle
stock_value_difference stays wrong even after a repost.

resolve the original dn/si item via the packed item's parent_detail_docname
when the direct lookup fails, so the return values from the original outward
bundle on both submit and repost.

* test(stock): cover batched packed-item return valuation on repost

(cherry picked from commit d37e905322)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-30 07:02:41 +00:00
Pandiyan P
a51750db56 fix(stock): pick list serial batch posting date (#57015)
* fix(stock): fall back to current date/time for serial and batch bundle posting datetime

Pick List has no posting_date/posting_time fields, so creating or updating a
Serial and Batch Bundle from a Pick List row crashed with
"TypeError: combine() argument 1 must be datetime.date, not None". Fall back
to today/now when the parent voucher doesn't carry its own posting date.

Fixes #56951

* fix(stock): accept a plain dict for add_serial_batch_ledgers' doc and child_row

The whitelisted add_serial_batch_ledgers only converted child_row into an
attribute-accessible frappe._dict when it arrived as a JSON string, and doc's
type hint only allowed Document | str. Frappe's JSON API delivers both as
plain dicts (see frappe.app.make_form_dict, which parses the request body
with orjson and only wraps the top-level dict, not nested values), so every
real request was rejected before the handler body ever ran: first with a
FrappeTypeError on doc, and once that's fixed, with an AttributeError on
child_row.serial_and_batch_bundle. parse_json already wraps a plain dict in
frappe._dict (and leaves a real Document instance untouched), so routing
child_row through it unconditionally fixes both.

(cherry picked from commit 7e46be2a33)
2026-07-10 06:11:45 +00:00
59 changed files with 1426 additions and 185 deletions

View File

@@ -3316,13 +3316,11 @@ def set_paid_amount_and_received_amount(
company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
if bank and company_currency != bank.account_currency:
# doc currency can be different from bank currency
posting_date = doc.get("posting_date") or doc.get("transaction_date")
conversion_rate = get_exchange_rate(
bank.account_currency, party_account_currency, posting_date
)
conversion_rate = get_exchange_rate(bank.account_currency, party_account_currency)
received_amount = paid_amount / conversion_rate
else:
received_amount = paid_amount * doc.get("conversion_rate", 1)
conversion_rate = get_exchange_rate(doc.get("currency", company_currency), company_currency)
received_amount = paid_amount * conversion_rate
# if payment type is pay, then paid amount and received amount are swapped
if payment_type == "Pay":

View File

@@ -33,6 +33,8 @@ frappe.ui.form.on("Payment Request", "onload", function (frm, dt, dn) {
});
frappe.ui.form.on("Payment Request", "refresh", function (frm) {
let sending_email = false;
if (
frm.doc.payment_request_type == "Inward" &&
frm.doc.payment_channel !== "Phone" &&
@@ -41,16 +43,16 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
frm.doc.docstatus == 1
) {
frm.add_custom_button(__("Resend Payment Email"), function () {
frappe.call({
method: "erpnext.accounts.doctype.payment_request.payment_request.resend_payment_email",
args: { docname: frm.doc.name },
freeze: true,
freeze_message: __("Sending"),
callback: function (r) {
if (!r.exc) {
frappe.msgprint(__("Message Sent"));
}
},
if (sending_email) {
frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
return;
}
sending_email = true;
frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
frm.call("resend_payment_email").then((r) => {
const msg = !r.exc ? __("Email Sent") : __("Email couldn't be sent.");
frappe.show_alert({ message: msg, indicator: !r.exc ? "green" : "red" });
sending_email = false;
});
});
}

View File

@@ -411,6 +411,18 @@ class PaymentRequest(Document):
return payment_entry
@frappe.whitelist(methods=["POST"])
def resend_payment_email(self):
if not (
self.docstatus == 1
and self.payment_request_type == "Inward"
and self.payment_channel != "Phone"
and self.status not in ["Initiated", "Paid"]
):
frappe.throw(_("Payment Link couldn't be sent."))
self.send_email()
def send_email(self):
"""send email with payment link"""
email_args = {
@@ -428,7 +440,17 @@ class PaymentRequest(Document):
)
],
}
enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
job_id = f"send_payment_email::{self.name}"
enqueue(
method=frappe.sendmail,
queue="short",
timeout=300,
is_async=True,
job_id=job_id,
deduplicate=True,
enqueue_after_commit=True,
**email_args,
)
def get_message(self):
"""return message with payment gateway link"""
@@ -827,11 +849,6 @@ def get_print_format_list(ref_doctype):
return {"print_format": print_format_list}
@frappe.whitelist()
def resend_payment_email(docname):
return frappe.get_doc("Payment Request", docname).send_email()
@frappe.whitelist()
def make_payment_entry(docname):
doc = frappe.get_doc("Payment Request", docname)

View File

@@ -15,6 +15,7 @@ from erpnext.accounts.doctype.account_closing_balance.account_closing_balance im
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
@@ -46,6 +47,14 @@ class PeriodClosingVoucher(AccountsController):
self.block_if_future_closing_voucher_exists()
self.check_closing_account_type()
self.check_closing_account_currency()
self.validate_accounts_not_frozen()
def validate_accounts_not_frozen(self, for_cancellation=False):
posting_date = self.period_end_date
if for_cancellation and is_immutable_ledger_enabled():
posting_date = getdate()
check_freezing_date(posting_date, self.company)
def validate_start_and_end_date(self):
self.fy_start_date, self.fy_end_date = frappe.db.get_value(
@@ -147,6 +156,7 @@ class PeriodClosingVoucher(AccountsController):
"Process Period Closing Voucher",
)
self.block_if_future_closing_voucher_exists()
self.validate_accounts_not_frozen(for_cancellation=True)
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
self.cancel_process_pcv_docs()

View File

@@ -473,19 +473,24 @@ def get_child_docs(doc: list) -> list:
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
docs_with_deferred_revenue = ()
docs_with_deferred_expense = ()
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if sales_docs:
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
if purchase_docs:
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if docs_with_deferred_revenue or docs_with_deferred_expense:
frappe.throw(

View File

@@ -2802,12 +2802,15 @@ class TestSalesInvoice(FrappeTestCase):
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company 1")
frappe.local.enable_perpetual_inventory["_Test Company 1"] = 1
old_inventory_account = frappe.db.get_value("Company", "_Test Company 1", "default_inventory_account")
frappe.db.set_value(
"Company",
"_Test Company 1",
"stock_received_but_not_billed",
"Stock Received But Not Billed - _TC1",
{
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC1",
"default_inventory_account": "Stock In Hand - _TC1",
},
)
frappe.db.set_value(
"Company",
@@ -2852,6 +2855,7 @@ class TestSalesInvoice(FrappeTestCase):
# tear down
frappe.local.enable_perpetual_inventory["_Test Company 1"] = old_perpetual_inventory
frappe.db.set_value("Company", "_Test Company 1", "default_inventory_account", old_inventory_account)
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", old_negative_stock)
def test_sle_for_target_warehouse(self):

View File

@@ -80,8 +80,7 @@
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"reqd": 1
"options": "Cost Center"
},
{
"fieldname": "shipping_amount_section",
@@ -139,18 +138,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,
"modified": "2019-05-25 23:12:26.156405",
"links": [],
"modified": "2026-07-22 14:53:27.315435",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Shipping Rule",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -196,5 +197,8 @@
"write": 1
}
],
"sort_order": "ASC"
}
"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
@@ -162,7 +161,14 @@ class ShippingRule(Document):
)
shipping_charge["add_deduct_tax"] = "Add"
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
shipping_charge_filters = shipping_charge.copy()
if not self.cost_center:
shipping_charge_filters["cost_center"] = (
"in",
(None, "", erpnext.get_default_cost_center(doc.company)),
)
existing_shipping_charge = doc.get("taxes", filters=shipping_charge_filters)
if existing_shipping_charge:
# take the last record found
existing_shipping_charge[-1].tax_amount = shipping_amount

View File

@@ -96,3 +96,29 @@ frappe.ui.form.on("Subscription", {
});
},
});
frappe.ui.form.on("Subscription Plan Detail", {
plan: function (frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (!row.plan) return;
const requested_plan = row.plan;
frappe.call({
method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions",
args: {
plan: requested_plan,
company: frm.doc.company,
party_type: frm.doc.party_type,
},
callback: function (r) {
if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return;
// Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten.
for (const [dimension, value] of Object.entries(r.message)) {
if (frm.fields_dict[dimension] && !frm.doc[dimension]) {
frm.set_value(dimension, value);
}
}
},
});
},
});

View File

@@ -26,6 +26,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account_currency
from erpnext.stock.doctype.item.item import get_item_defaults
class InvoiceCancelled(frappe.ValidationError):
@@ -747,6 +748,39 @@ def get_prorata_factor(
return diff / plan_days
@frappe.whitelist()
def get_plan_dimensions(
plan: str, company: str | None = None, party_type: str | None = None
) -> dict[str, str]:
"""Resolve a plan's accounting dimensions, falling back to the plan item's company defaults."""
plan_doc = frappe.get_cached_doc("Subscription Plan", plan)
dimensions = {}
for dimension in ["cost_center", *get_accounting_dimensions()]:
value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type)
if value:
dimensions[dimension] = value
return dimensions
def get_item_dimension(
item_code: str, dimension: str, company: str | None, party_type: str | None
) -> str | None:
if not company:
return None
item_defaults = get_item_defaults(item_code, company)
if dimension != "cost_center":
return item_defaults.get(dimension)
selling = item_defaults.get("selling_cost_center")
buying = item_defaults.get("buying_cost_center")
if party_type == "Supplier":
return buying or selling
return selling or buying
def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None:
"""
Task to updates the status of all `Subscription` apart from those that are cancelled

View File

@@ -17,7 +17,7 @@ from frappe.utils.data import (
nowdate,
)
from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
from erpnext.accounts.doctype.subscription.subscription import get_plan_dimensions, get_prorata_factor
test_dependencies = ("UOM", "Item Group", "Item")
@@ -583,6 +583,48 @@ class TestSubscription(FrappeTestCase):
subscription.process(nowdate())
self.assertEqual(len(subscription.invoices), 1)
def test_plan_dimensions_resolve_from_plan_then_item(self):
from erpnext.stock.doctype.item.test_item import make_item
# Plan-level cost center takes precedence.
create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR")
frappe.db.set_value(
"Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC"
)
self.assertEqual(
get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"),
"_Test Cost Center - _TC",
)
# No plan cost center: fall back to the item's company default (selling vs buying by party type).
item = make_item(
"_Test Sub Dimension Item",
{
"is_stock_item": 0,
"item_defaults": [
{
"company": "_Test Company",
"default_warehouse": "_Test Warehouse - _TC",
"selling_cost_center": "_Test Cost Center - _TC",
"buying_cost_center": "_Test Cost Center 2 - _TC",
}
],
},
)
create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name)
self.assertEqual(
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"),
"_Test Cost Center - _TC",
)
self.assertEqual(
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"),
"_Test Cost Center 2 - _TC",
)
# Without a company the item fallback is skipped.
self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC"))
def make_plans():
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")

View File

@@ -849,9 +849,11 @@ def validate_account_party_type(self):
def get_dashboard_info(party_type, party, loyalty_program=None):
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
if not frappe.has_permission(doctype, "read"):
return None
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]

View File

@@ -106,6 +106,7 @@ class ReceivablePayableReport:
def get_data(self):
self.get_sales_invoices_or_customers_based_on_sales_person()
self.get_invoices_based_on_sales_partner()
# Get invoice details like bill_no, due_date etc for all invoices
self.get_invoice_details()
@@ -241,6 +242,12 @@ class ReceivablePayableReport:
):
return
if self.filters.get("sales_partner"):
# a return is folded onto the invoice it settles, so match that invoice's
# partner (like the sales_person filter above), not the return's own
if ple.against_voucher_no not in self.sales_partner_invoices:
return
if self.filters.get("ignore_accounts"):
key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
else:
@@ -469,7 +476,7 @@ class ReceivablePayableReport:
"company": self.filters.company,
"docstatus": 1,
},
fields=["name", "due_date", "po_no"],
fields=["name", "due_date", "po_no", "sales_partner"],
)
for d in si_list:
self.invoice_details.setdefault(d.name, d)
@@ -903,6 +910,22 @@ class ReceivablePayableReport:
for d in records:
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
def get_invoices_based_on_sales_partner(self):
if not self.filters.get("sales_partner"):
return
self.sales_partner_invoices = set(
frappe.get_all(
"Sales Invoice",
filters={
"sales_partner": self.filters.get("sales_partner"),
"docstatus": 1,
"company": self.filters.company,
},
pluck="name",
)
)
def prepare_conditions(self):
self.qb_selection_filter = []
self.or_filters = []
@@ -1005,15 +1028,6 @@ class ReceivablePayableReport:
self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt]))
if self.filters.get("sales_partner"):
self.qb_selection_filter.append(
self.ple.party.isin(
qb.from_(self.customer)
.select(self.customer.name)
.where(self.customer.default_sales_partner == self.filters.get("sales_partner"))
)
)
def exclude_employee_transaction(self):
self.qb_selection_filter.append(self.ple.party_type != "Employee")
@@ -1113,9 +1127,6 @@ class ReceivablePayableReport:
if self.account_type == "Receivable":
fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"]
if self.filters.get("sales_partner"):
fields.append("default_sales_partner")
self.party_details[party] = frappe.db.get_value(
"Customer",
party,
@@ -1242,7 +1253,7 @@ class ReceivablePayableReport:
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
if self.filters.sales_partner:
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
if self.filters.account_type == "Payable":
self.add_column(

View File

@@ -7,6 +7,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
@@ -1303,3 +1304,61 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
self.assertIn(original_customer, parties)
self.assertNotIn(second_customer, parties)
self.assertEqual(allowed_invoice.customer, original_customer)
def test_receivable_filtered_by_sales_partner(self):
frappe.set_user("Administrator")
partner_a, partner_b = "_Test AR Sales Partner A", "_Test AR Sales Partner B"
for partner in (partner_a, partner_b):
if not frappe.db.exists("Sales Partner", partner):
frappe.get_doc(
{
"doctype": "Sales Partner",
"partner_name": partner,
"commission_rate": 0,
"territory": "All Territories",
}
).insert()
def _si(sales_partner):
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2)
si.sales_partner = sales_partner
return si.save().submit()
partner_a_si = _si(partner_a)
partner_b_si = _si(partner_b)
no_partner_si = _si(None)
# a return is folded onto the invoice it settles, so it nets against that
# invoice's partner even when the return's own partner is cleared
no_partner_return = make_return_doc("Sales Invoice", partner_a_si.name)
no_partner_return.sales_partner = None
no_partner_return.items[0].qty = -1
no_partner_return.update_outstanding_for_self = 0
no_partner_return.save().submit()
filters = {
"company": self.company,
"party_type": "Customer",
"report_date": today(),
"range": "30, 60, 90, 120",
}
def rows_for(partner):
return {
r.voucher_no: r
for r in execute({**filters, "sales_partner": partner})[1]
if r.get("voucher_no")
}
rows_a = rows_for(partner_a)
self.assertIn(partner_a_si.name, rows_a)
self.assertEqual(rows_a[partner_a_si.name].sales_partner, partner_a)
self.assertNotIn(partner_b_si.name, rows_a)
self.assertNotIn(no_partner_si.name, rows_a)
self.assertNotIn(no_partner_return.name, rows_a)
self.assertEqual(rows_a[partner_a_si.name].credit_note, 100)
self.assertEqual(rows_a[partner_a_si.name].outstanding, 100)
rows_b = rows_for(partner_b)
self.assertIn(partner_b_si.name, rows_b)
self.assertNotIn(partner_a_si.name, rows_b)

View File

@@ -132,8 +132,8 @@ class AccountsReceivableSummary(ReceivablePayableReport):
if row.sales_person:
self.party_total[row.party].sales_person.append(row.get("sales_person", ""))
if self.filters.sales_partner:
self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "")
if self.filters.sales_partner and row.get("sales_partner"):
self.party_total[row.party]["sales_partner"] = row.get("sales_partner")
def get_columns(self):
self.columns = []
@@ -191,7 +191,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
if self.filters.sales_partner:
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
else:
self.add_column(

View File

@@ -193,3 +193,42 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
report = execute(filters)
rpt_output = report[1]
self.assertEqual(len(rpt_output), 0)
def test_03_summary_sales_partner_column(self):
partner = "_Test AR Summary Sales Partner"
if not frappe.db.exists("Sales Partner", partner):
frappe.get_doc(
{
"doctype": "Sales Partner",
"partner_name": partner,
"commission_rate": 0,
"territory": "All Territories",
}
).insert()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=200,
price_list_rate=200,
do_not_submit=True,
)
si.sales_partner = partner
si.save().submit()
filters = {
"company": self.company,
"customer": self.customer,
"posting_date": today(),
"range": "30, 60, 90, 120",
"sales_partner": partner,
}
rpt_output = execute(filters)[1]
self.assertEqual(len(rpt_output), 1)
self.assertEqual(rpt_output[0].get("sales_partner"), partner)

View File

@@ -1239,7 +1239,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
return {
"company": purchase_doc.company,
"purchase_date": purchase_doc.get("posting_date"),
"gross_purchase_amount": flt(first_item.base_net_amount),
"gross_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
"asset_quantity": first_item.qty,
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
"asset_location": first_item.get("asset_location"),

View File

@@ -734,6 +734,7 @@ def get_target_asset_details(asset=None, company=None):
@frappe.whitelist()
def get_consumed_stock_item_details(args):
frappe.has_permission("Stock Ledger Entry", throw=True)
if isinstance(args, str):
args = json.loads(args)
@@ -743,6 +744,7 @@ def get_consumed_stock_item_details(args):
item = frappe._dict()
if args.item_code:
item = frappe.get_cached_doc("Item", args.item_code)
item.check_permission()
out.item_name = item.item_name
out.batch_no = None
@@ -752,6 +754,8 @@ def get_consumed_stock_item_details(args):
out.stock_uom = item.stock_uom
out.warehouse = get_item_warehouse(item, args, overwrite_warehouse=True) if item else None
if out.warehouse:
frappe.has_permission("Warehouse", doc=out.warehouse, throw=True)
# Cost Center
item_defaults = get_item_defaults(item.name, args.company)
@@ -792,6 +796,9 @@ def get_warehouse_details(args):
out = {}
if args.warehouse and args.item_code:
frappe.has_permission("Item", doc=args.item_code, throw=True)
frappe.has_permission("Warehouse", doc=args.warehouse, throw=True)
frappe.has_permission("Stock Ledger Entry", throw=True)
out = {
"actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0,
"valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False),

View File

@@ -358,7 +358,7 @@ class BuyingController(SubcontractingController):
)
valuation_amount_adjustment -= item.item_tax_amount
self.round_floats_in(item)
self.round_floats_in(item, do_not_round_fields=["conversion_factor"])
if flt(item.conversion_factor) == 0.0:
item.conversion_factor = (
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0

View File

@@ -336,6 +336,7 @@ def create_variant(item, args, use_template_image=False):
@frappe.whitelist()
def enqueue_multiple_variant_creation(item, args, use_template_image=False):
frappe.has_permission("Item", ptype="create", throw=True)
use_template_image = frappe.parse_json(use_template_image)
# There can be innumerable attribute combinations, enqueue
if isinstance(args, str):

View File

@@ -306,7 +306,9 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_project_name(doctype, txt, searchfield, start, page_len, filters):
def get_project_name(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
):
proj = qb.DocType("Project")
qb_filter_and_conditions = []
qb_filter_or_conditions = []
@@ -321,7 +323,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
if filters.get("company"):
qb_filter_and_conditions.append(proj.company == filters.get("company"))
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"]))
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
q = qb.from_(proj)

View File

@@ -158,10 +158,28 @@ def validate_returned_items(doc):
):
frappe.throw(_("Warehouse is mandatory"))
items_returned = True
if doc.doctype in (
"Purchase Invoice",
"Purchase Receipt",
"Subcontracting Receipt",
"Sales Invoice",
"Delivery Note",
"POS Invoice",
):
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
items_returned = True
else:
items_returned = True
elif d.item_name:
items_returned = True
if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"):
# No item_code here means no linked Item, so there's no accepted/rejected
# split to speak of - received_qty isn't a meaningful independent signal.
# Only a negative qty (i.e. a real negative billing amount) counts.
if flt(d.qty) < 0:
items_returned = True
else:
items_returned = True
if not items_returned:
frappe.throw(_("Atleast one item should be entered with negative quantity in return document"))

View File

@@ -177,13 +177,18 @@ class StockController(AccountsController):
)
is_asset_pr = any(d.get("is_fixed_asset") for d in self.get("items"))
need_inventory_map = (self.get_stock_items() or self.get("packed_items")) and cint(
erpnext.is_perpetual_inventory_enabled(self.company)
)
if (
cint(erpnext.is_perpetual_inventory_enabled(self.company))
or provisional_accounting_for_non_stock_items
or is_asset_pr
):
warehouse_account = get_warehouse_account_map(self.company)
warehouse_account = frappe._dict()
if need_inventory_map:
warehouse_account = get_warehouse_account_map(self.company)
if self.docstatus == 1:
if not gl_entries:

View File

@@ -185,7 +185,12 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
do_not_round_fields = [
"valuation_rate",
"incoming_rate",
"sales_incoming_rate",
"conversion_factor",
]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)

View File

@@ -0,0 +1,89 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
class TestSalesAndPurchaseReturn(FrappeTestCase):
@staticmethod
def _cancel_and_delete(doctype, name):
if not frappe.db.exists(doctype, name):
return
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=1)
def test_purchase_invoice_zero_qty_return_is_rejected(self):
# A return with every item at qty 0 moves no stock and no value, so it must be
# rejected the same way a return with no items at all would be.
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
pi = make_purchase_invoice(qty=10)
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
return_pi = make_purchase_invoice(
is_return=1,
return_against=pi.name,
qty=0,
do_not_save=True,
)
self.assertRaises(frappe.ValidationError, return_pi.save)
def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self):
# Item Code is not mandatory on Purchase Invoice Item - a row can have only an
# item_name (e.g. a free-text/non-stock line). Such rows fall through to the
# item_name-only branch, which must also reject an all-zero-qty return instead
# of unconditionally treating the row as returned.
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True)
pi.items[0].item_code = ""
pi.save()
pi.submit()
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
return_pi = make_purchase_invoice(
item_name="_Test Item",
is_return=1,
return_against=pi.name,
qty=0,
do_not_save=True,
)
return_pi.items[0].item_code = ""
self.assertRaises(frappe.ValidationError, return_pi.save)
def test_delivery_note_zero_qty_return_is_rejected(self):
# A return with every item at qty 0 moves no stock and no value, so it must be
# rejected the same way a return with no items at all would be.
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
dn = create_delivery_note(qty=5)
self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
return_dn = make_sales_return(dn.name)
return_dn.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_dn.insert)
def test_sales_invoice_zero_qty_return_is_rejected(self):
# Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on
# every row must be rejected, not silently accepted as a no-op credit note.
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.controllers.sales_and_purchase_return import make_return_doc
si = create_sales_invoice(qty=10)
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
return_si = make_return_doc(si.doctype, si.name)
return_si.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_si.save)

View File

@@ -132,6 +132,7 @@ class Opportunity(TransactionBase, CRMNote):
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.map_fields()
self.validate_qty()
self.set_exchange_rate()
if not self.title:
@@ -142,6 +143,15 @@ class Opportunity(TransactionBase, CRMNote):
def on_update(self):
self.update_prospect()
def validate_qty(self):
for item in self.items:
if flt(item.qty) <= 0:
frappe.throw(
_("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
item.idx, item.item_code
)
)
def map_fields(self):
for field in self.meta.get_valid_columns():
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):

View File

@@ -67,6 +67,14 @@ class PlantFloor(Document):
@frappe.whitelist()
def get_stock_summary(warehouse, start=0, item_code=None, item_group=None):
frappe.has_permission("Warehouse", doc=warehouse, throw=True)
if item_code:
frappe.has_permission("Item", doc=item_code, throw=True)
if item_group:
frappe.has_permission("Item Group", doc=item_group, throw=True)
stock_details = get_stock_details(warehouse, start=start, item_code=item_code, item_group=item_group)
max_count = 0.0

View File

@@ -2,9 +2,9 @@
<div class="row" style="border-bottom:1px solid var(--border-color); padding:4px 5px; margin-top: 3px;margin-bottom: 3px;">
<div class="col-sm-1">
{% if(row.image) { %}
<img style="width:50px;height:50px;" src="{{row.image}}">
<img style="width:50px;height:50px;" src="{{frappe.utils.escape_html(row.image)}}">
{% } else { %}
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(row.item_code, 2)}}</div>
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(frappe.utils.escape_html(row.item_code), 2)}}</div>
{% } %}
</div>
<div class="col-sm-3">
@@ -13,7 +13,7 @@
{% } else { %}
{{row.item_link}}
<p>
{{row.item_name}}
{{frappe.utils.escape_html(row.item_name)}}
</p>
{% } %}
@@ -52,10 +52,10 @@
</span>
</div>
<div class="col-sm-1">
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ escape(row.item_code) }}">{{ __("Add") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Add") }}</button>
</div>
<div class="col-sm-1">
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ escape(row.item_code) }}">{{ __("Move") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Move") }}</button>
</div>
</div>
{% }); %}

View File

@@ -46,3 +46,60 @@ frappe.views.calendar["Work Order"] = {
],
get_events_method: "frappe.desk.calendar.get_events",
};
const WORK_ORDER_GANTT_COLORS = {
Draft: "red",
Stopped: "red",
"Not Started": "red",
"In Process": "orange",
Completed: "green",
"Stock Reserved": "blue",
"Stock Partially Reserved": "orange",
Cancelled: "gray",
};
if (!frappe.views.GanttView.prototype._work_order_status_colors) {
frappe.views.GanttView.prototype._work_order_status_colors = true;
const prepare_tasks = frappe.views.GanttView.prototype.prepare_tasks;
frappe.views.GanttView.prototype.prepare_tasks = function () {
prepare_tasks.call(this);
if (this.doctype === "Work Order") {
set_work_order_bar_classes(this);
}
};
const set_colors = frappe.views.GanttView.prototype.set_colors;
frappe.views.GanttView.prototype.set_colors = function () {
set_colors.call(this);
if (this.doctype === "Work Order") {
set_work_order_bar_styles(this);
}
};
}
function set_work_order_bar_classes(view) {
view.tasks.forEach((task, idx) => {
const color = WORK_ORDER_GANTT_COLORS[view.data[idx].status];
if (color) {
task.custom_class = "wo-" + color;
}
});
}
function set_work_order_bar_styles(view) {
const style = [...new Set(Object.values(WORK_ORDER_GANTT_COLORS))]
.map(
(color) => `
.gantt .bar-wrapper.wo-${color} .bar {
fill: var(--${color}-300);
}
.gantt .bar-wrapper.wo-${color} .bar-progress {
fill: var(--${color}-300);
}
`
)
.join("");
view.$result.prepend(`<style>${style}</style>`);
}

View File

@@ -418,6 +418,6 @@ def get_workstations(**kwargs):
d.background_color = color_map.get(d.status, "var(--red-600)")
d.workstation_link = get_url_to_form("Workstation", d.name)
if d.status != "Production":
d.status_image = d.off_status_image
d.status_image = frappe.utils.escape_html(d.off_status_image)
return data

View File

@@ -446,3 +446,4 @@ erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
erpnext.patches.v15_0.fix_titles
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root

View File

@@ -0,0 +1,23 @@
import frappe
from frappe.utils.nestedset import get_root_of
SEEDED_ROOT = "All Item Groups"
def execute():
"""Collapse the "All Item Groups" node seeded under a pre-existing root.
Setup seeding always inserted "All Item Groups" as a parentless group. On a
site where another app had already created the root (under a translated
name), it was re-parented instead, leaving a second group-root holding the
standard Item Groups.
"""
root = get_root_of("Item Group")
if not root or root == SEEDED_ROOT:
return
seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True)
if not seeded or not seeded.is_group or seeded.parent_item_group != root:
return
frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False)

View File

@@ -282,6 +282,23 @@ class TestProject(FrappeTestCase):
project.save()
self.assertEqual(project.percent_complete, 100)
def test_on_hold_project_keeps_status(self):
project, tasks = self._project_with_tasks("Task Completion", 4)
# an On hold project is not auto-flipped to Completed even at 100%
project.status = "On hold"
for task in tasks:
frappe.db.set_value("Task", task, "status", "Completed")
project.update_percent_complete()
self.assertEqual(project.percent_complete, 100)
self.assertEqual(project.status, "On hold")
# nor auto-flipped back to Open when below 100%
frappe.db.set_value("Task", tasks[0], "status", "Open")
project.update_percent_complete()
self.assertEqual(project.percent_complete, 75)
self.assertEqual(project.status, "On hold")
def _create_portal_user(self, email):
"""A user with no Project-related role, so read access can only come from
control_access_for_project_users() sharing the doc with them."""

View File

@@ -15,6 +15,12 @@ frappe.ui.form.on("Task", {
},
onload: function (frm) {
frm.set_query("project", function () {
return {
query: "erpnext.controllers.queries.get_project_name",
};
});
frm.set_query("task", "depends_on", function () {
let filters = {
name: ["!=", frm.doc.name],

View File

@@ -30,6 +30,7 @@ frappe.ui.form.on("Timesheet", {
return {
filters: {
company: frm.doc.company,
status: "Open",
},
};
};
@@ -122,6 +123,7 @@ frappe.ui.form.on("Timesheet", {
return {
filters: {
customer: doc.customer,
status: "Open",
},
};
});

View File

@@ -22,7 +22,7 @@ frappe.query_reports["Project Summary"] = {
fieldname: "status",
label: __("Status"),
fieldtype: "Select",
options: "\nOpen\nCompleted\nCancelled",
options: "\nOpen\nOn hold\nCompleted\nCancelled",
default: "Open",
},
{

View File

@@ -126,11 +126,26 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}
}
get_item_fields_to_round() {
const [item] = this.frm.doc.items || [];
if (!item) {
return [];
}
const do_not_round_fields = ["conversion_factor"];
return frappe.meta
.get_fieldnames(item.doctype, item.parent, {
fieldtype: ["in", ["Currency", "Float"]],
})
.filter((fieldname) => !do_not_round_fields.includes(fieldname));
}
calculate_item_values() {
var me = this;
if (!this.discount_amount_applied) {
const fields_to_round = this.get_item_fields_to_round();
for (const item of this.frm.doc.items || []) {
frappe.model.round_floats_in(item);
frappe.model.round_floats_in(item, fields_to_round);
item.net_rate = item.rate;
item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;

View File

@@ -1,17 +1,19 @@
<div class="app-listing item-list image-view-container item-selector">
{% for (var i=0; i < data.length; i++) { var item = data[i]; %}
{% const item_name = frappe.utils.escape_html(item.name); %}
{% const item_title = frappe.utils.escape_html(item.item_name || item.name); %}
{% if (i % 4 === 0) { %}<div class="image-view-row">{% } %}
<div class="image-view-item" data-name="{{ item.name }}">
<div class="image-view-item" data-name="{{ item_name }}">
<div class="image-view-header doclist-row">
<div class="list-value">
<a class="grey list-id" data-name="{{item.name}}"
title="{{ item.item_name || item.name}}">
{{item.item_name || item.name}}</a>
<a class="grey list-id" data-name="{{ item_name }}"
title="{{ item_title }}">
{{ item_title }}</a>
</div>
</div>
<div class="image-view-body">
<a data-item-code="{{ item.name }}"
title="{{ item.item_name || item.name }}"
<a data-item-code="{{ item_name }}"
title="{{ item_title }}"
>
<div class="image-field"
style="
@@ -22,11 +24,11 @@
>
{% if (!item.image) { %}
<span class="placeholder-text">
{%= frappe.get_abbr(item.item_name || item.name) %}
{%= frappe.get_abbr(item_title) %}
</span>
{% } %}
{% if (item.image) { %}
<img src="{{ item.image }}" alt="{{item.item_name || item.name}}">
<img src="{{ frappe.utils.escape_html(item.image) }}" alt="{{ item_title }}">
{% } %}
</div>
</a>

View File

@@ -1,4 +1,5 @@
{% $.each(workstations, (idx, row) => { %}
{% const row_workstation_name = frappe.utils.escape_html(row.name); %}
<div class="workstation-wrapper">
<div class="workstation-status text-right">
{% if(row.status == "Production") { %}
@@ -17,14 +18,14 @@
{% if(row.status_image) { %}
<img class="workstation-image-cls" src="{{row.status_image}}">
{% } else { %}
<div class="workstation-image-cls workstation-abbr">{{frappe.get_abbr(row.name, 2)}}</div>
<div class="workstation-image-cls workstation-abbr">{{frappe.get_abbr(row_workstation_name, 2)}}</div>
{% } %}
</a>
</div>
</div>
<div class="workstation-card" style="display: grid;">
<span class="ellipsis" title="{{row.name}}">
{{row.workstation_name}}
<span class="ellipsis" title="{{row_workstation_name}}">
{{row_workstation_name}}
</span>
</div>
</div>

View File

@@ -147,6 +147,9 @@ class Quotation(SellingController):
make_packing_list(self)
def after_insert(self):
self.carry_forward_communication()
def before_submit(self):
self.set_has_alternative_item()
@@ -292,7 +295,6 @@ class Quotation(SellingController):
# update enquiry status
self.update_opportunity("Quotation")
self.update_lead()
self.carry_forward_communication()
def on_cancel(self):
if self.lost_reasons:

View File

@@ -1984,6 +1984,41 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
sales_order.save()
self.assertEqual(sales_order.taxes[0].tax_amount, 0)
def test_sales_order_with_shipping_rule_without_cost_center(self):
from erpnext import get_default_cost_center
shipping_rule = frappe.get_doc(
{
"doctype": "Shipping Rule",
"label": "Shipping Rule Without Cost Center - Sales Order Test",
"shipping_rule_type": "Selling",
"company": "_Test Company",
"account": "_Test Account Shipping Charges - _TC",
"calculate_based_on": "Fixed",
"shipping_amount": 50,
}
).insert()
sales_order = make_sales_order(do_not_save=True)
sales_order.shipping_rule = shipping_rule.name
company_cost_center = get_default_cost_center(sales_order.company)
shipping_rule.apply(sales_order)
self.assertEqual(len(sales_order.taxes), 1)
self.assertIsNone(sales_order.taxes[0].cost_center)
for cost_center in (None, "", company_cost_center):
sales_order.taxes[0].cost_center = cost_center
shipping_rule.apply(sales_order)
self.assertEqual(len(sales_order.taxes), 1)
self.assertEqual(sales_order.taxes[0].cost_center, cost_center)
sales_order.taxes[0].cost_center = ""
sales_order.save()
sales_order.reload()
shipping_rule.apply(sales_order)
self.assertEqual(len(sales_order.taxes), 1)
self.assertEqual(sales_order.taxes[0].cost_center, "")
@change_settings(
"Accounts Settings",
{"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 1},

View File

@@ -53,7 +53,7 @@ class Employee(NestedSet):
user = frappe.get_doc("User", existing_user_id)
validate_employee_role(user, ignore_emp_check=True)
user.save(ignore_permissions=True)
remove_user_permission("Employee", self.name, existing_user_id)
remove_user_permission("Employee", self.name, existing_user_id, ignore_permissions=True)
def after_rename(self, old, new, merge):
self.db_set("employee", new)
@@ -91,11 +91,11 @@ class Employee(NestedSet):
)
if employee_user_permission_exists and not self.create_user_permission:
remove_user_permission("Employee", self.name, self.user_id)
remove_user_permission("Company", self.company, self.user_id)
remove_user_permission("Employee", self.name, self.user_id, ignore_permissions=True)
remove_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
elif not employee_user_permission_exists and self.create_user_permission:
add_user_permission("Employee", self.name, self.user_id)
add_user_permission("Company", self.company, self.user_id)
add_user_permission("Employee", self.name, self.user_id, ignore_permissions=True)
add_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
def update_user(self):
# add employee role if missing

View File

@@ -16,6 +16,8 @@ from frappe.utils.nestedset import (
test_records = frappe.get_test_records("Item Group")
TRANSLATED_ROOT = "Todos os Grupos de Itens"
class TestItem(unittest.TestCase):
def test_basic_tree(self, records=None):
@@ -234,3 +236,46 @@ class TestItem(unittest.TestCase):
"_Test Item Group B - 3",
merge=True,
)
def test_patch_merges_seeded_root_into_existing_root(self):
from erpnext.patches.v16_0.merge_seeded_item_group_root import execute
self.nest_root_under(TRANSLATED_ROOT)
self.assertEqual(
frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT
)
execute()
self.assertFalse(frappe.db.exists("Item Group", "All Item Groups"))
self.assertEqual(self.get_root_names(), [TRANSLATED_ROOT])
self.assertEqual(
frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT
)
self.test_basic_tree()
# restore the original root name for the tests that follow
frappe.rename_doc("Item Group", TRANSLATED_ROOT, "All Item Groups")
self.assertEqual(self.get_root_names(), ["All Item Groups"])
self.test_basic_tree()
def nest_root_under(self, new_root):
"""Recreate the tree left behind by seeding a root under a pre-existing one."""
frappe.get_doc(
{
"doctype": "Item Group",
"item_group_name": new_root,
"is_group": 1,
"parent_item_group": "All Item Groups",
}
).insert()
ig = frappe.qb.DocType("Item Group")
frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run()
frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run()
rebuild_tree("Item Group", "parent_item_group")
def get_root_names(self):
return frappe.db.sql_list(
"""select name from `tabItem Group` where ifnull(parent_item_group, '')=''"""
)

View File

@@ -13,6 +13,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import (
)
from frappe.desk.page.setup_wizard.setup_wizard import make_records
from frappe.utils import cstr, getdate
from frappe.utils.nestedset import get_root_of
from erpnext.accounts.doctype.account.account import RootNotEditable
from erpnext.regional.address_template.setup import set_up_address_templates
@@ -24,46 +25,48 @@ def read_lines(filename: str) -> list[str]:
def install(country=None):
root_item_group = get_root_of("Item Group") or _("All Item Groups")
records = [
# ensure at least an empty Address Template exists for this Country
{"doctype": "Address Template", "country": country},
# item group
{
"doctype": "Item Group",
"item_group_name": _("All Item Groups"),
"item_group_name": root_item_group,
"is_group": 1,
"parent_item_group": "",
"__condition": lambda: not frappe.db.exists("Item Group", root_item_group),
},
{
"doctype": "Item Group",
"item_group_name": _("Products"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
"show_in_website": 1,
},
{
"doctype": "Item Group",
"item_group_name": _("Raw Material"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Services"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Sub Assemblies"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Consumable"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
# Stock Entry Type
{

View File

@@ -79,10 +79,13 @@ def get_warehouse_account(warehouse, warehouse_account=None):
account = get_company_default_inventory_account(warehouse.company)
if not account and warehouse.company:
account = frappe.db.get_value(
"Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, "name"
inventory_accounts = frappe.get_all(
"Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, pluck="name"
)
if len(inventory_accounts) == 1:
account = inventory_accounts[0]
if not account and warehouse.company and not warehouse.is_group:
frappe.throw(
_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format(

View File

@@ -706,6 +706,76 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(gle_warehouse_amount, 1400)
def test_return_bundle_voucher_detail_no_as_packed_item(self):
"""Return bundle whose voucher_detail_no is the Packed Item (SLE-driven path) must still value on repost."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
warehouse = "_Test Warehouse - _TC"
packed_item = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BATCH-DN-RET-VDN-.#####",
}
).name
bundle_item = make_item(properties={"is_stock_item": 0, "is_sales_item": 1}).name
make_product_bundle(bundle_item, [packed_item], qty=20)
make_stock_entry(item_code=packed_item, target=warehouse, qty=60, basic_rate=35)
dn = create_delivery_note(item_code=bundle_item, warehouse=warehouse, qty=3)
return_dn = make_sales_return(dn.name)
return_dn.items[0].qty = -2
return_dn.submit()
return_dn.reload()
packed_row = return_dn.packed_items[0]
bundle = frappe.get_doc("Serial and Batch Bundle", packed_row.serial_and_batch_bundle)
# Reproduce the reported state: bundle points at the Packed Item (not the DN Item), valuation at 0.
bundle.db_set("voucher_detail_no", packed_row.name)
bundle.db_set({"avg_rate": 0, "total_amount": 0})
for entry in bundle.entries:
entry.db_set({"incoming_rate": 0, "stock_value_difference": 0})
packed_row.db_set("incoming_rate", 0)
frappe.db.set_value(
"Stock Ledger Entry",
{
"voucher_type": "Delivery Note",
"voucher_no": return_dn.name,
"item_code": packed_item,
"is_cancelled": 0,
},
{"incoming_rate": 0, "stock_value_difference": 0},
)
frappe.get_doc(
doctype="Repost Item Valuation",
based_on="Transaction",
voucher_type="Delivery Note",
voucher_no=return_dn.name,
posting_date=return_dn.posting_date,
posting_time=return_dn.posting_time,
).submit()
bundle.reload()
self.assertEqual(flt(bundle.avg_rate), 35)
incoming_rate, stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Delivery Note",
"voucher_no": return_dn.name,
"item_code": packed_item,
"is_cancelled": 0,
},
["incoming_rate", "stock_value_difference"],
)
self.assertEqual(flt(incoming_rate), 35)
self.assertEqual(flt(stock_value_difference), 1400)
def test_bin_details_of_packed_item(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -837,7 +837,17 @@ class Item(Document):
frappe.throw(_("Item {0} is not a template item.").format(frappe.bold(self.variant_of)))
if based_on == "Item Attribute":
previous_doc = self.get_doc_before_save()
saved_attributes = (
{(row.attribute, row.attribute_value) for row in previous_doc.attributes}
if previous_doc
else set()
)
for d in self.attributes:
if (d.attribute, d.attribute_value) in saved_attributes:
continue
if not frappe.db.exists(
"Item Variant Attribute", {"attribute": d.attribute, "parent": self.variant_of}
):

View File

@@ -360,6 +360,45 @@ class TestItem(FrappeTestCase):
self.assertRaises(InvalidItemAttributeValueError, attribute.save)
frappe.db.rollback()
def test_disabled_attribute_blocks_only_attribute_changes(self):
frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template-L", force=1)
frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template", force=1)
frappe.delete_doc_if_exists("Item Attribute", "_Test Disabled Size", force=1)
attribute = frappe.get_doc(
{
"doctype": "Item Attribute",
"attribute_name": "_Test Disabled Size",
"item_attribute_values": [
{"attribute_value": "Large", "abbr": "L"},
{"attribute_value": "Small", "abbr": "S"},
],
}
).insert()
template = make_item(
"_Test Disabled Attribute Template",
{
"has_variants": 1,
"variant_based_on": "Item Attribute",
"attributes": [{"attribute": attribute.name}],
},
)
variant = create_variant(template.name, {attribute.name: "Large"})
variant.save()
attribute.disabled = 1
attribute.save()
variant.reload()
variant.description = "Edited after the attribute was disabled"
variant.save()
variant.reload()
variant.attributes[0].attribute_value = "Small"
self.assertRaises(frappe.ValidationError, variant.save)
def test_rename_attribute_value_updates_variants(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)

View File

@@ -194,8 +194,10 @@ class TestLandedCostVoucher(FrappeTestCase):
epi = is_perpetual_inventory_enabled(company_a)
company_doc = frappe.get_doc("Company", company_a)
old_inventory_account = company_doc.default_inventory_account
company_doc.enable_perpetual_inventory = 1
company_doc.stock_received_but_not_billed = srbnb
company_doc.default_inventory_account = "Stock In Hand - _TC"
company_doc.save()
pr = make_purchase_receipt(
@@ -223,7 +225,11 @@ class TestLandedCostVoucher(FrappeTestCase):
distribute_landed_cost_on_items(lcv)
lcv.submit()
frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi)
frappe.db.set_value(
"Company",
company_a,
{"enable_perpetual_inventory": epi, "default_inventory_account": old_inventory_account},
)
frappe.local.enable_perpetual_inventory = {}
def test_landed_cost_voucher_for_zero_purchase_rate(self):

View File

@@ -795,6 +795,28 @@ class TestMaterialRequest(FrappeTestCase):
mr = frappe.get_doc("Material Request", mr.name)
self.assertEqual(mr.per_ordered, 100)
def test_fractional_conversion_factor_for_purchase(self):
item = create_item("_Test Fractional Conversion Item", stock_uom="Kg", is_purchase_item=1)
conversion_factor = 0.453592292
mr = make_material_request(
item_code=item.name,
qty=1000,
uom="Pound",
conversion_factor=conversion_factor,
)
mr.reload()
self.assertEqual(mr.items[0].conversion_factor, conversion_factor)
po = make_purchase_order(mr.name)
po.supplier = "_Test Supplier"
po.insert()
po.reload()
self.assertEqual(po.items[0].conversion_factor, conversion_factor)
self.assertEqual(po.items[0].stock_qty, mr.items[0].stock_qty)
def test_customer_provided_parts_mr(self):
create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0)
existing_requested_qty = self._get_requested_qty("_Test Customer", "_Test Warehouse - _TC")

View File

@@ -404,29 +404,10 @@ class PurchaseReceipt(BuyingController):
self.set_consumed_qty_in_subcontract_order()
self.reserve_stock_for_sales_order()
def check_next_docstatus(self):
submit_rv = frappe.db.sql(
"""select t1.name
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
(self.name),
)
if submit_rv:
frappe.throw(_("Purchase Invoice {0} is already submitted").format(self.submit_rv[0][0]))
def on_cancel(self):
super().on_cancel()
self.check_on_hold_or_closed_status()
# Check if Purchase Invoice has been submitted against current Purchase Order
submitted = frappe.db.sql(
"""select t1.name
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
self.name,
)
if submitted:
frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0]))
self.update_prevdoc_status()
self.update_billing_status()

View File

@@ -3263,11 +3263,14 @@ class TestPurchaseReceipt(FrappeTestCase):
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company")
frappe.local.enable_perpetual_inventory["_Test Company"] = 1
old_inventory_account = frappe.db.get_value("Company", "_Test Company", "default_inventory_account")
frappe.db.set_value(
"Company",
"_Test Company",
"stock_received_but_not_billed",
"Stock Received But Not Billed - _TC",
{
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
"default_inventory_account": "Stock In Hand - _TC",
},
)
pr = make_purchase_receipt(qty=10, rate=1000, do_not_submit=1)
@@ -3296,13 +3299,14 @@ class TestPurchaseReceipt(FrappeTestCase):
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=False)
warehouse_account = get_warehouse_account_map("_Test Company")
expected_gle = (
("Stock Received But Not Billed - _TC", 0, 10000, "Main - _TC"),
("Freight and Forwarding Charges - _TC", 0, 2000, "Main - _TC"),
("Expenses Included In Valuation - _TC", 0, 2000, "Main - _TC"),
(warehouse_account[pr.items[0].warehouse]["account"], 14000, 0, "Main - _TC"),
("Stock Received But Not Billed - _TC", 0.0, 10000.0, "Main - _TC"),
("Freight and Forwarding Charges - _TC", 0.0, 2000.0, "Main - _TC"),
("Expenses Included In Valuation - _TC", 0.0, 2000.0, "Main - _TC"),
(warehouse_account[pr.items[0].warehouse]["account"], 14000.0, 0.0, "Main - _TC"),
)
self.assertSequenceEqual(expected_gle, gl_entries)
self.assertCountEqual(expected_gle, gl_entries)
frappe.local.enable_perpetual_inventory["_Test Company"] = old_perpetual_inventory
frappe.db.set_value("Company", "_Test Company", "default_inventory_account", old_inventory_account)
def test_manufacturing_and_expiry_date_for_batch(self):
item = make_item(
@@ -5446,6 +5450,32 @@ class TestPurchaseReceipt(FrappeTestCase):
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
def test_cancel_blocked_by_submitted_invoice_rolls_back(self):
"""A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink
check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole
transaction has to roll back: the receipt stays submitted with no leaked ledger entries."""
pr = make_purchase_receipt()
pi = make_purchase_invoice(pr.name)
pi.insert()
pi.submit()
pr.reload()
status_before = pr.status
sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name})
gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name})
frappe.db.savepoint("before_blocked_cancel")
with self.assertRaises(frappe.LinkExistsError) as cm:
pr.cancel()
self.assertIn(pi.name, str(cm.exception))
frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback
pr.reload()
self.assertEqual(pr.docstatus, 1)
self.assertEqual(pr.status, status_before)
self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before)
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before)
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -505,6 +505,11 @@ class SerialandBatchBundle(Document):
self.child_table, self.voucher_detail_no, field
)
if not return_against_voucher_detail_no and self.voucher_type in ("Delivery Note", "Sales Invoice"):
# Bundles built via the use_serial_batch_fields / SLE-driven path keep the Packed Item
# as voucher_detail_no (not remapped to the DN/SI Item), so the lookup above misses.
return_against_voucher_detail_no = self.get_return_against_packed_item(field)
filters = [
["Serial and Batch Bundle", "voucher_no", "=", return_against],
["Serial and Batch Entry", "docstatus", "=", 1],
@@ -548,6 +553,16 @@ class SerialandBatchBundle(Document):
return valuation_details
def get_return_against_packed_item(self, field):
"""Resolve the original DN/SI Item when a return bundle's voucher_detail_no is the Packed Item."""
parent_detail_docname = frappe.db.get_value(
"Packed Item", self.voucher_detail_no, "parent_detail_docname"
)
if not parent_detail_docname:
return
return frappe.db.get_value(self.child_table, parent_detail_docname, field)
def get_legacy_valuation_rate_for_return_entry(
self, return_against, return_against_voucher_detail_no, return_warehouse=None
):

View File

@@ -1222,9 +1222,11 @@ class StockEntry(StockController):
first_row_by_item.setdefault(key, item)
for key, transfer_qty in transfer_by_item.items():
pending_qty = max(0.0, pending_by_item[key])
item = first_row_by_item[key]
precision = item.precision("qty")
transfer_qty = flt(transfer_qty, precision)
pending_qty = max(0.0, flt(pending_by_item[key], precision))
if transfer_qty > pending_qty:
item = first_row_by_item[key]
frappe.throw(
_(
"Row #{0}: Cannot transfer {1} {2} of Item {3}. "

View File

@@ -934,6 +934,38 @@ class TestStockEntry(FrappeTestCase):
fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item 2", stock_entry.get("items"))).amount
self.assertEqual(fg_cost, flt(rm_cost + bom_operation_cost + work_order.additional_operating_cost, 2))
@change_settings("System Settings", {"float_precision": 3})
@change_settings("Manufacturing Settings", {"backflush_raw_materials_based_on": "BOM"})
def test_material_transfer_for_manufacture_qty_precision(self):
work_order = frappe.new_doc("Work Order")
work_order.append(
"required_items",
{
"item_code": "_Test Item",
"required_qty": 33.876,
"transferred_qty": 33.875,
},
)
stock_entry = frappe.new_doc("Stock Entry")
stock_entry.work_order = "Test Work Order"
stock_entry.append(
"items",
{
"item_code": "_Test Item",
"s_warehouse": "_Test Warehouse - _TC",
"qty": 0.001,
"uom": "Nos",
},
)
stock_entry.pro_doc = work_order
stock_entry._validate_no_excess_transfer()
stock_entry.items[0].qty = 0.002
with self.assertRaises(frappe.ValidationError):
stock_entry._validate_no_excess_transfer()
@change_settings("Manufacturing Settings", {"material_consumption": 1})
def test_work_order_manufacture_with_material_consumption(self):
from erpnext.manufacturing.doctype.work_order.work_order import (

View File

@@ -1292,6 +1292,7 @@ def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_d
return flt(result[0][0]) if result and result[0][0] else 0.0
# nosemgrep: missing-argument-type-hint
@frappe.whitelist()
def get_stock_balance_for(
item_code: str,
@@ -1364,7 +1365,7 @@ def get_stock_balance_for(
or 0
)
if row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
if row and row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
rate = get_incoming_rate(
frappe._dict(
{

View File

@@ -103,6 +103,44 @@ class TestWarehouse(FrappeTestCase):
children = get_children("Warehouse", parent=company, company=company, is_root=True)
self.assertTrue(any(wh["value"] == "_Test Warehouse - _TC" for wh in children))
def test_inventory_account_fallback_with_multiple_stock_accounts(self):
from erpnext.stock import get_warehouse_account
company = create_inventory_fallback_company()
frappe.db.set_value("Company", company, "default_inventory_account", None)
if frappe.db.exists("Account", "Extra Inventory Account - _TCIF"):
frappe.delete_doc("Account", "Extra Inventory Account - _TCIF")
warehouse = frappe.get_doc("Warehouse", {"company": company, "is_group": 0})
single_account = frappe.db.get_value(
"Account", {"account_type": "Stock", "is_group": 0, "company": company}, "name"
)
self.assertEqual(get_warehouse_account(warehouse), single_account)
create_account(
account_name="Extra Inventory Account",
parent_account=frappe.db.get_value("Account", single_account, "parent_account"),
account_type="Stock",
company=company,
)
self.assertRaises(frappe.ValidationError, get_warehouse_account, warehouse)
def create_inventory_fallback_company():
company = "_Test Company Inventory Fallback"
if not frappe.db.exists("Company", company):
frappe.get_doc(
{
"doctype": "Company",
"company_name": company,
"abbr": "_TCIF",
"default_currency": "INR",
"enable_perpetual_inventory": 0,
"country": "India",
}
).insert(ignore_permissions=True)
return company
def create_warehouse(warehouse_name, properties=None, company=None):
if not company:

View File

@@ -7,8 +7,14 @@ from collections import defaultdict
import frappe
from frappe import _
<<<<<<< HEAD
from frappe.query_builder.functions import CombineDatetime, Sum
=======
from frappe.query_builder.functions import IfNull, Sum
>>>>>>> 0dbe410414 (fix(stock): handle multi-item opening balance in Stock Ledger report (#57591))
from frappe.utils import cint, flt, get_datetime
from pypika import Order
from pypika.analytics import RowNumber
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -53,14 +59,15 @@ def execute(filters=None):
data = []
conversion_factors = []
if opening_row:
data.append(opening_row)
opening_rows = opening_row if isinstance(opening_row, list) else ([opening_row] if opening_row else [])
for row in opening_rows:
data.append(row)
conversion_factors.append(0)
actual_qty = stock_value = 0
if opening_row:
actual_qty = opening_row.get("qty_after_transaction")
stock_value = opening_row.get("stock_value")
if opening_rows:
actual_qty = opening_rows[0].get("qty_after_transaction", 0)
stock_value = opening_rows[0].get("stock_value", 0)
available_serial_nos = {}
@@ -687,43 +694,120 @@ def get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value=N
if not (filters.item_code and filters.warehouse and filters.from_date):
return
from erpnext.stock.stock_ledger import get_previous_sle
item_codes = filters.item_code
if isinstance(item_codes, str):
item_codes = [item_codes]
project = None
if filters.get("project") and not frappe.get_all(
"Inventory Dimension", filters={"reference_document": "Project"}
):
project = filters.get("project")
warehouses = get_matching_warehouses(filters.warehouse)
if not warehouses:
return
last_entry = get_previous_sle(
{
"item_code": filters.item_code,
"warehouse_condition": get_warehouse_condition(filters.warehouse),
"posting_date": filters.from_date,
"posting_time": "00:00:00",
"project": project,
},
for_report=True,
sle_doctype = frappe.qb.DocType("Stock Ledger Entry")
sr_doctype = frappe.qb.DocType("Stock Reconciliation")
opening_reco_query = (
frappe.qb.from_(sle_doctype)
.inner_join(sr_doctype)
.on(sle_doctype.voucher_no == sr_doctype.name)
.select(sle_doctype.voucher_no)
.where(sle_doctype.docstatus < 2)
.where(sle_doctype.is_cancelled == 0)
.where(sle_doctype.item_code.isin(item_codes))
.where(sle_doctype.warehouse.isin(warehouses))
.where(sle_doctype.voucher_type == "Stock Reconciliation")
.where(sle_doctype.posting_date == filters.from_date)
.where(sr_doctype.purpose == "Opening Stock")
)
# check if any SLEs are actually Opening Stock Reconciliation
for sle in list(sl_entries):
if (
sle.get("voucher_type") == "Stock Reconciliation"
and sle.posting_date == filters.from_date
and frappe.db.get_value("Stock Reconciliation", sle.voucher_no, "purpose") == "Opening Stock"
):
last_entry = sle
sl_entries.remove(sle)
opening_reco_vouchers = set(opening_reco_query.run(pluck=True))
row = {
if opening_reco_vouchers:
sl_entries[:] = [sle for sle in sl_entries if sle.get("voucher_no") not in opening_reco_vouchers]
sle_cond = (sle_doctype.posting_date < filters.from_date) | (
(sle_doctype.posting_date == filters.from_date) & (sle_doctype.posting_time == "00:00:00")
)
if opening_reco_vouchers:
sle_cond = sle_cond | (
(sle_doctype.posting_date == filters.from_date)
& (sle_doctype.voucher_no.isin(list(opening_reco_vouchers)))
)
subq = (
frappe.qb.from_(sle_doctype)
.select(
sle_doctype.qty_after_transaction,
sle_doctype.stock_value,
RowNumber()
.over(sle_doctype.item_code, sle_doctype.warehouse)
.orderby(sle_doctype.posting_datetime, sle_doctype.creation, sle_doctype.name, order=Order.desc)
.as_("rn"),
)
.where(sle_doctype.docstatus < 2)
.where(sle_doctype.is_cancelled == 0)
.where(sle_doctype.item_code.isin(item_codes))
.where(sle_doctype.warehouse.isin(warehouses))
.where(sle_cond)
)
for field in ["voucher_no", "project", "company"]:
if filters.get(field):
subq = subq.where(sle_doctype[field] == filters.get(field))
inventory_dimension_fields = get_inventory_dimension_fields()
if inventory_dimension_fields:
for fieldname in inventory_dimension_fields:
if filters.get(fieldname):
subq = subq.where(sle_doctype[fieldname].isin(filters.get(fieldname)))
query = (
frappe.qb.from_(subq)
.select(
IfNull(Sum(subq.qty_after_transaction), 0.0).as_("total_qty"),
IfNull(Sum(subq.stock_value), 0.0).as_("total_stock_value"),
)
.where(subq.rn == 1)
)
res = query.run(as_dict=True)
total_qty = flt(res[0].total_qty) if res else 0.0
total_stock_value = flt(res[0].total_stock_value) if res else 0.0
valuation_rate = flt(total_stock_value / total_qty) if total_qty else 0.0
return {
"item_code": _("'Opening'"),
"qty_after_transaction": last_entry.get("qty_after_transaction", 0),
"valuation_rate": last_entry.get("valuation_rate", 0),
"stock_value": last_entry.get("stock_value", 0),
"qty_after_transaction": total_qty,
"valuation_rate": valuation_rate,
"stock_value": total_stock_value,
}
return row
def get_matching_warehouses(warehouses):
if not warehouses:
return []
if isinstance(warehouses, str):
warehouses = [warehouses]
warehouse_details = frappe.get_all(
"Warehouse",
filters={"name": ("in", warehouses)},
fields=["lft", "rgt"],
)
if not warehouse_details:
return warehouses
wh = frappe.qb.DocType("Warehouse")
cond = None
for d in warehouse_details:
c = (wh.lft >= d.lft) & (wh.rgt <= d.rgt)
cond = c if cond is None else (cond | c)
matching = (frappe.qb.from_(wh).select(wh.name).where(cond)).run(pluck=True)
return matching if matching else warehouses
def get_warehouse_condition(warehouses):
@@ -779,7 +863,15 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value):
if not filters.item_code or not filters.warehouse or not filters.from_date:
return
if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1:
item_codes = filters.get("item_code")
if isinstance(item_codes, str):
item_codes = [item_codes]
warehouses = filters.get("warehouse")
if isinstance(warehouses, str):
warehouses = [warehouses]
if len(item_codes) > 1 or len(warehouses) > 1:
return
sl_doctype = frappe.qb.DocType("Stock Ledger Entry")
@@ -799,17 +891,11 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value):
)
)
if filters.get("item_code"):
if isinstance(filters.item_code, list | tuple):
query = query.where(sl_doctype.item_code.isin(filters.item_code))
else:
query = query.where(sl_doctype.item_code == filters.item_code)
if item_codes:
query = query.where(sl_doctype.item_code.isin(item_codes))
if filters.get("warehouse"):
if isinstance(filters.warehouse, list | tuple):
query = query.where(sl_doctype.warehouse.isin(filters.warehouse))
else:
query = query.where(sl_doctype.warehouse == filters.warehouse)
if warehouses:
query = query.where(sl_doctype.warehouse.isin(warehouses))
for key, value in inv_dimension_wise_value.items():
if isinstance(value, list | tuple):

View File

@@ -20,5 +20,298 @@ class TestStockLedgerReeport(FrappeTestCase):
item_code=["_Test Stock Report Serial Item"],
)
<<<<<<< HEAD
def tearDown(self) -> None:
frappe.db.rollback()
=======
rows = self.run_report(item)
receipt = next(row for row in rows if row.get("in_qty"))
issue = next(row for row in rows if row.get("out_qty"))
self.assertEqual(receipt["in_qty"], 10)
self.assertEqual(receipt["qty_after_transaction"], 10)
self.assertEqual(issue["out_qty"], -4)
self.assertEqual(issue["qty_after_transaction"], 6)
def test_opening_balance_reflects_movements_before_from_date(self):
item = "_Test Item"
self.make_movements(
item,
[
{
"qty": 10,
"to_warehouse": WAREHOUSE,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
},
{"qty": 4, "from_warehouse": WAREHOUSE, "posting_date": today()},
],
)
rows = self.run_report(item, from_date=add_days(today(), -5), to_date=today())
# the receipt predates the range, so it surfaces as the opening balance
self.assertEqual(rows[0]["item_code"], "'Opening'")
self.assertEqual(rows[0]["qty_after_transaction"], 10)
# the in-range issue draws down from the opening balance
issue = next(row for row in rows if row.get("out_qty"))
self.assertEqual(issue["qty_after_transaction"], 6)
def test_filters_to_requested_item_only(self):
item_a = "_Test Item"
item_b = "_Test Item 2"
self.make_movements(item_a, [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 100}])
self.make_movements(item_b, [{"qty": 7, "to_warehouse": WAREHOUSE, "basic_rate": 100}])
rows = self.run_report(item_a)
item_codes = {row["item_code"] for row in rows if row.get("voucher_no")}
self.assertEqual(item_codes, {item_a})
def test_multi_item_opening_balance_with_and_without_transactions(self):
item_a = "_Test Item"
item_b = "_Test Item 2"
self.make_movements(
item_a,
[
{
"qty": 10,
"to_warehouse": WAREHOUSE,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
}
],
)
self.make_movements(
item_b,
[{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 50, "posting_date": add_days(today(), -10)}],
)
self.make_movements(
item_a,
[{"qty": 2, "from_warehouse": WAREHOUSE, "posting_date": today()}],
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item_a, item_b],
warehouse=WAREHOUSE,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], 15)
def test_multi_warehouse_opening_balance_aggregation(self):
item = "_Test Item"
warehouse_1 = "Stores - _TC"
warehouse_2 = "Finished Goods - _TC"
self.make_movements(
item,
[
{
"qty": 10,
"to_warehouse": warehouse_1,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
},
{
"qty": 20,
"to_warehouse": warehouse_2,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
},
],
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item],
warehouse=[warehouse_1, warehouse_2],
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], 30)
def test_opening_stock_reconciliation_on_from_date_non_midnight_time(self):
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
item = "_Test Item"
from_date = today()
sr = create_stock_reconciliation(
item_code=item,
warehouse=WAREHOUSE,
qty=25,
rate=100,
posting_date=from_date,
posting_time="10:30:00",
purpose="Opening Stock",
do_not_submit=False,
)
filters = frappe._dict(
company="_Test Company",
from_date=from_date,
to_date=from_date,
item_code=[item],
warehouse=WAREHOUSE,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], 25)
# Ensure the Opening Stock Reconciliation is not duplicated in detail transaction rows
reco_rows = [row for row in rows if row.get("voucher_no") == sr.name]
self.assertEqual(len(reco_rows), 0)
def test_backdated_sle_independent_maxima_handling(self):
item = "_Test Item"
# Entry 1: Later posting date (2026-07-20), created first
self.make_movements(
item,
[
{
"qty": 10,
"to_warehouse": WAREHOUSE,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
}
],
)
# Entry 2: Backdated posting date (2026-07-15), created LATER
self.make_movements(
item,
[
{
"qty": 5,
"to_warehouse": WAREHOUSE,
"basic_rate": 100,
"posting_date": add_days(today(), -15),
}
],
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item],
warehouse=WAREHOUSE,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
# Should correctly pick the latest posting date entry (15 Qty) despite backdated creation order
self.assertEqual(opening_rows[0]["qty_after_transaction"], 15)
def test_filtered_opening_balance_does_not_pick_excluded_creation_entry(self):
item = "_Test Item"
posting_date = add_days(today(), -10)
posting_time = "09:00:00"
included_entry = make_stock_entry(
item_code=item,
qty=10,
to_warehouse=WAREHOUSE,
basic_rate=100,
posting_date=posting_date,
posting_time=posting_time,
)
make_stock_entry(
item_code=item,
qty=50,
to_warehouse=WAREHOUSE,
basic_rate=100,
posting_date=posting_date,
posting_time=posting_time,
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item],
warehouse=WAREHOUSE,
voucher_no=included_entry.name,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], 10)
def test_tied_creation_terminal_sle_is_not_summed_twice(self):
item = "_Test Item"
posting_date = add_days(today(), -10)
posting_time = "09:00:00"
stock_entry_1 = make_stock_entry(
item_code=item,
qty=10,
to_warehouse=WAREHOUSE,
basic_rate=100,
posting_date=posting_date,
posting_time=posting_time,
)
stock_entry_2 = make_stock_entry(
item_code=item,
qty=5,
to_warehouse=WAREHOUSE,
basic_rate=100,
posting_date=posting_date,
posting_time=posting_time,
)
sle_rows = frappe.get_all(
"Stock Ledger Entry",
filters={
"voucher_type": "Stock Entry",
"voucher_no": ("in", [stock_entry_1.name, stock_entry_2.name]),
"item_code": item,
"warehouse": WAREHOUSE,
"is_cancelled": 0,
},
fields=["name", "qty_after_transaction"],
order_by="name desc",
)
self.assertEqual(len(sle_rows), 2)
for sle in sle_rows:
frappe.db.set_value(
"Stock Ledger Entry",
sle.name,
"creation",
"2026-01-01 00:00:00.000000",
update_modified=False,
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item],
warehouse=WAREHOUSE,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], sle_rows[0].qty_after_transaction)
self.assertNotEqual(
opening_rows[0]["qty_after_transaction"],
sum(sle.qty_after_transaction for sle in sle_rows),
)
>>>>>>> 0dbe410414 (fix(stock): handle multi-item opening balance in Stock Ledger report (#57591))