Compare commits

...

137 Commits

Author SHA1 Message Date
MochaMind
da4b61d7b7 fix: Esperanto translations 2026-08-03 14:31:29 +05:30
MochaMind
61643e87f9 fix: Serbian (Latin) translations 2026-08-03 14:31:23 +05:30
MochaMind
7ea8ee0dac fix: Norwegian Bokmal translations 2026-08-03 14:31:18 +05:30
MochaMind
048698b166 fix: Uzbek translations 2026-08-03 14:31:13 +05:30
MochaMind
83a0d5df6b fix: Bosnian translations 2026-08-03 14:31:07 +05:30
MochaMind
051a65b58f fix: Burmese translations 2026-08-03 14:31:03 +05:30
MochaMind
54bb4fe926 fix: Hindi translations 2026-08-03 14:30:58 +05:30
MochaMind
6d0bf73b30 fix: Croatian translations 2026-08-03 14:30:53 +05:30
MochaMind
b0b5978d15 fix: Thai translations 2026-08-03 14:30:47 +05:30
MochaMind
1d411dfd15 fix: Persian translations 2026-08-03 14:30:40 +05:30
MochaMind
6da2dae96e fix: Indonesian translations 2026-08-03 14:30:34 +05:30
MochaMind
953e616ae6 fix: Portuguese, Brazilian translations 2026-08-03 14:30:29 +05:30
MochaMind
7dd7077025 fix: Vietnamese translations 2026-08-03 14:30:25 +05:30
MochaMind
d1f1f0ac6e fix: Chinese Traditional translations 2026-08-03 14:30:19 +05:30
MochaMind
9eaa5ebdbc fix: Chinese Simplified translations 2026-08-03 14:30:14 +05:30
MochaMind
b1d92978ad fix: Turkish translations 2026-08-03 14:30:09 +05:30
MochaMind
5877c18623 fix: Swedish translations 2026-08-03 14:30:05 +05:30
MochaMind
0c98d8d6a0 fix: Serbian (Cyrillic) translations 2026-08-03 14:29:59 +05:30
MochaMind
ce3ad93007 fix: Slovenian translations 2026-08-03 14:29:55 +05:30
MochaMind
ba6eea682e fix: Russian translations 2026-08-03 14:29:50 +05:30
MochaMind
f8e2a67138 fix: Portuguese translations 2026-08-03 14:29:46 +05:30
MochaMind
2b195c8a5e fix: Polish translations 2026-08-03 14:29:41 +05:30
MochaMind
083fd6a4c3 fix: Dutch translations 2026-08-03 14:29:37 +05:30
MochaMind
ebecece493 fix: Korean translations 2026-08-03 14:29:31 +05:30
MochaMind
c2e44ba9df fix: Italian translations 2026-08-03 14:29:27 +05:30
MochaMind
90918c268f fix: Hungarian translations 2026-08-03 14:29:22 +05:30
MochaMind
b9380a3775 fix: German translations 2026-08-03 14:29:18 +05:30
MochaMind
dae10c3197 fix: Danish translations 2026-08-03 14:29:13 +05:30
MochaMind
4153cdebae fix: Czech translations 2026-08-03 14:29:09 +05:30
MochaMind
c2538a23b8 fix: Bulgarian translations 2026-08-03 14:29:04 +05:30
MochaMind
c1ee2f03e9 fix: Arabic translations 2026-08-03 14:29:00 +05:30
MochaMind
e06e4626c4 fix: Spanish translations 2026-08-03 14:28:55 +05:30
MochaMind
a863a07168 fix: French translations 2026-08-03 14:28:51 +05:30
MochaMind
da2fb4df9a fix: Romanian translations 2026-08-03 14:28:46 +05:30
Mihir Kandoi
eeab2a833f Merge pull request #57730 from frappe/mergify/bp/version-16-hotfix/pr-57647
fix(sales): reject sales returns where every item has zero quantity (backport #57647)
2026-08-03 13:44:09 +05:30
Mihir Kandoi
af4aea171b test(sales): import make_sales_return from delivery_note on version-16-hotfix 2026-08-03 13:29:40 +05:30
Mihir Kandoi
a2dfc9e50a Merge pull request #57728 from frappe/mergify/bp/version-16-hotfix/pr-57725
fix(stock): scope over deliver/receive role check to delivery and receipt overflow (backport #57725)
2026-08-03 13:13:22 +05:30
R-Jayaraman
f2a53247c5 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)
2026-08-03 07:35:29 +00:00
R-Jayaraman
aa71cd695b 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)
2026-08-03 07:35:29 +00:00
Mihir Kandoi
697f68d1d2 fix: resolve version-16 backport conflicts
Keep validate_warehouses() alongside the new
validate_over_delivery_receipt_allowance() call.

Drop test_blanket_order_over_order_aggregated_across_rows: it is develop-only
context the cherry-pick swallowed into the conflict, not part of #57725.

Revert the valuation_method literal to the three options this branch offers -
Standard Cost rode along from a regenerated develop type block.
2026-08-03 12:55:08 +05:30
Mihir Kandoi
246ffee17c Merge pull request #57722 from frappe/mergify/bp/version-16-hotfix/pr-57645
fix(purchase): reject purchase returns where every item has zero quan… (backport #57645)
2026-08-03 12:54:59 +05:30
Mihir Kandoi
e7757f6d0b Merge pull request #57726 from frappe/mergify/bp/version-16-hotfix/pr-57097
fix(stock): read quality inspection readings in the user's number format  (backport #57097)
2026-08-03 12:42:52 +05:30
Mihir Kandoi
10229700c0 test(purchase): drop unrelated sales-return test from the backport
test_sales_return_validates_against_original came in with the new file,
not with the change being backported. It covers a raw-SQL to query-builder
conversion that only exists on develop, and it imports
erpnext.stock.doctype.delivery_note.mapper, a module version-16-hotfix
does not have.
2026-08-03 12:42:10 +05:30
Afsal Syed
6cbf73a326 test(stock): prevent settings leakage in purchase order tests
(cherry picked from commit 99630f40eb)
2026-08-03 07:10:14 +00:00
Afsal Syed
3a0f988a9e test(stock): add test cases verifying stock over delivery role does not bypass order allowance
(cherry picked from commit 0b271e24b6)

# Conflicts:
#	erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py
2026-08-03 07:10:14 +00:00
Afsal Syed
4713ddd55b fix(stock): scope over deliver/receive role check to delivery and receipt overflow
(cherry picked from commit 248873034d)
2026-08-03 07:10:14 +00:00
Afsal Syed
3f3292ca4a fix(stock): validate over delivery/receipt allowance in stock settings
(cherry picked from commit 446ec6030a)

# Conflicts:
#	erpnext/stock/doctype/stock_settings/stock_settings.json
#	erpnext/stock/doctype/stock_settings/stock_settings.py
2026-08-03 07:10:14 +00:00
Mihir Kandoi
656db1c2fe fix(stock): resolve backport conflict in quality inspection imports
The backport left both import hunks unresolved, so the file did not compile.
version-16-hotfix keeps item_query unannotated and still imports cstr, so only
get_number_format_info goes, replaced by NumberFormat; typing.Any is not
carried over because nothing on this branch uses it.
2026-08-03 12:31:55 +05:30
Mihir Kandoi
444dd9e817 test(stock): cover reading number formats end to end
Set the number format on the session user rather than on System Settings: the
code reads the user default, which shadows the global one, so these tests never
exercised the path they were written for. Restoring it in a finally also keeps
a failed assertion from leaving the whole suite in another locale.

Add a table test over every format in NUMBER_FORMAT_MAP, covering the grouped
values and the three formats parse_float used to read as 0, and restore the
formula-based coverage for non-numeric readings.

(cherry picked from commit 00d17ca5db)
2026-08-03 06:56:49 +00:00
Mihir Kandoi
fccf1220f6 fix(stock): accept every number a reading can be written as
parse_float and is_valid_number each re-derived the number grammar, so the
validator accepted strings flt() cannot parse: str.isdigit() lets superscripts
through and lstrip("+-") lets repeated signs through, both then silently scored
as 0. One parse_reading() returning None when float() refuses the value makes
acceptance and conversion true by construction.

The grammar was also wrong for several formats. Where the group separator is
not a dot, a dot-decimal reading such as 1.15 parsed correctly before and is
accepted again. #,### and #.### report no decimal separator at all, which
rejected every fractional reading outright and, for #.###, reread a stored
1.500 as 1500.0; they now fall back to a dot and give up the grouping that
would collide with it.

Only readings that change are checked, so an inspection entered by a user in
one locale stays saveable and submittable by a user in another, and manual
inspection rows keep the free text they were never parsed for.

NumberFormat replaces get_number_format_info, which frappe drops in v16.

(cherry picked from commit 5b5f354090)

# Conflicts:
#	erpnext/stock/doctype/quality_inspection/quality_inspection.py
2026-08-03 06:56:48 +00:00
Sudharsanan11
113b5ecaec test(stock): cover quality inspection readings in every number format
covers the reported case, a 1,15 reading in the space grouped "# ###,##"
format, which was read as 115 and rejected. also covers the dot grouped
comma format, and asserts that a reading written with the wrong separator,
or one that is not a number at all, is now rejected with an error rather
than read as a different value.

(cherry picked from commit b1f188146e)
2026-08-03 06:56:48 +00:00
Sudharsanan11
e2466780b9 test(stock): drop non numeric reading from formula based quality inspection
a numeric reading of "random text" was read as 0 and pulled the mean from
0.6 down to 0.4, which the test then asserted as accepted. such a reading
is now rejected outright, and the test is about formula evaluation, so drop
the row. its assertions are unchanged.

(cherry picked from commit 3752be809f)
2026-08-03 06:56:47 +00:00
Sudharsanan11
3b7fb6851a fix(stock): read quality inspection readings in the user's number format
readings are Data fields, so they are parsed server side. parse_float only
swapped the separators for "#.###,##", so in the space grouped "# ###,##"
(polish) a reading of 1,15 was read as 115, fell outside the acceptance
range and silently rejected the inspection. strip whatever the group
separator is and normalise whatever the decimal separator is instead.

it also read the global number format, while the desk formats numbers with
the user's own. a user whose locale differs from the site therefore typed
readings in a format the server did not parse them with. read the user
default, which falls back to the global one.

a reading that is not a valid number in that format is now rejected with an
error instead of being read as a different number.

(cherry picked from commit e74c0a3cdb)
2026-08-03 06:56:46 +00:00
R-Jayaraman
b0f2704bde 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:17:09 +00:00
R-Jayaraman
032b922f0c 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:17:08 +00:00
MochaMind
833ccd3358 chore: update POT file (#57706) 2026-08-02 14:31:18 +02:00
Mihir Kandoi
4a5c416ee0 Merge pull request #57698 from frappe/mergify/bp/version-16-hotfix/pr-57676
feat: select a supplier per item when creating Purchase Orders from a Material Request (backport #57676)
2026-08-02 12:35:56 +05:30
Mihir Kandoi
4b6a4cc9c5 Merge pull request #57701 from frappe/mergify/bp/version-16-hotfix/pr-57699
fix: prevent duplicate shipping charges without cost center (backport #57699)
2026-08-02 12:29:29 +05:30
Mihir Kandoi
e5f8d0c84b fix: apply the supplier selection to this branch's own mapper module
The backport carried develop's mapper module across whole, while version 16
keeps its mappers in material_request.py. That left two copies of the mapping
layer: the dialog and the new tests reached for the imported module, and
make_purchase_order, which the rest of the branch and the older tests use, never
learned to set the supplier - so test_make_purchase_order_sets_supplier failed.

The feature now sits in material_request.py alongside the mappers it extends,
and the imported module is dropped.
2026-08-02 12:25:22 +05:30
Mihir Kandoi
7f81502cde chore: remove shipping rule comments
(cherry picked from commit 106ecd7120)
2026-08-02 06:49:06 +00:00
Mihir Kandoi
666b6167a1 fix: prevent duplicate shipping charges without cost center
(cherry picked from commit a4134af30b)
2026-08-02 06:49:06 +00:00
Mihir Kandoi
65a53a7012 Merge pull request #57697 from frappe/mergify/bp/version-16-hotfix/pr-57674
fix: preserve UOM conversion factor precision in transactions (backport #57674)
2026-08-02 12:06:19 +05:30
Mihir Kandoi
e98471d9c9 fix: resolve version 16 backport conflicts 2026-08-02 12:03:47 +05:30
Mihir Kandoi
187840b559 fix: label the items table in the supplier selection dialog
The grid template always renders its label line, so leaving the table unlabelled
left an empty line hanging above the description.

(cherry picked from commit 2e72846670)
2026-08-02 06:26:26 +00:00
Mihir Kandoi
2c9db13041 fix: keep the bulk supplier field to half the supplier selection dialog
A lone Link field stretched the full width of the dialog, which reads as a
search bar rather than a field. A column break holds it to half.

(cherry picked from commit 44fdf7bea9)
2026-08-02 06:26:26 +00:00
Mihir Kandoi
9b647bed5c feat: set one supplier across every item in the supplier selection dialog
A Material Request where few items carry a default supplier meant picking the
same supplier row by row. A Supplier field above the table copies its value
into every row, leaving the exceptions to be corrected by hand.

Both pickers skip suppliers that are disabled or barred from Purchase Orders by
their scorecard standing.

(cherry picked from commit e84bf44e51)
2026-08-02 06:26:25 +00:00
Mihir Kandoi
93331a1cf0 fix: warn about existing draft orders before the supplier selection creates more
Creating through the dialog calls the endpoint directly instead of going
through open_mapped_doc, so the draft link guard that every other Create action
runs never fired, and a repeated dialog quietly produced a second set of draft
orders for the same quantity.

(cherry picked from commit f0bb70539d)
2026-08-02 06:26:25 +00:00
Mihir Kandoi
380ee3b013 test: reject the same Material Request item twice in one supplier selection
(cherry picked from commit 8ffe5ba420)
2026-08-02 06:26:25 +00:00
Mihir Kandoi
ea770f6a8e fix: reject the same Material Request item twice in one supplier selection
Each row was checked against the pending quantity on its own, so a payload that
listed one item under two suppliers passed both checks and ordered the pending
quantity twice. The dialog cannot produce that, a direct call to the endpoint
can.

(cherry picked from commit 99d56cc850)
2026-08-02 06:26:25 +00:00
Mihir Kandoi
06a753faf3 fix: escape item code and UOM in the supplier dialog errors
Desk renders a client side message as HTML, so an Item or UOM whose name holds
markup ran as markup in the buyer's session.

(cherry picked from commit 21c6d10ad3)
2026-08-02 06:26:24 +00:00
Mihir Kandoi
e71cef02b3 fix: open the Purchase Order when the supplier selection creates only one
Naming a single order in a message and leaving the buyer to click it is a step
for nothing. The form opens directly when there is one order; the message stays
for the case it was meant for, several orders at once.

(cherry picked from commit 3856eaa35e)
2026-08-02 06:26:24 +00:00
Mihir Kandoi
6096e761b0 test: reject a supplier selection without items
(cherry picked from commit d233fdf198)
2026-08-02 06:26:24 +00:00
Mihir Kandoi
d6ee5436b8 feat: order only the items ticked in the supplier selection dialog
Every row is ticked when the dialog opens, so the common case of ordering
everything is unchanged, and a buyer who wants a partial order unticks what
should wait. Creating with nothing ticked is rejected.

(cherry picked from commit 07445b3675)
2026-08-02 06:26:23 +00:00
Mihir Kandoi
810b9ae28f fix: link the item and spell out the unit in the supplier dialog errors
A bare item code left the buyer to find the item themselves, and a bare number
gave no clue what the limit was counted in. Both messages now link the item and
state the pending quantity in bold with its UOM.

(cherry picked from commit 5a78e2290a)
2026-08-02 06:26:23 +00:00
Mihir Kandoi
6b056ebb36 test: alert when Required By falls back to today
(cherry picked from commit 671c289303)
2026-08-02 06:26:22 +00:00
Mihir Kandoi
1adeb66bdc feat: alert when Required By falls back to today
Items whose requested date has passed silently got today as Required By, which
is a date the buyer never asked for. A toast now says so.

(cherry picked from commit 53e09dfdd6)
2026-08-02 06:26:22 +00:00
Mihir Kandoi
5aaefec747 feat: show the UOM alongside the quantity in the supplier selection dialog
The quantity is meaningless without the unit it is counted in, which the buyer
had to look up on the Material Request itself.

(cherry picked from commit d0cae2eb9c)
2026-08-02 06:26:22 +00:00
Mihir Kandoi
d2fe4b623c fix: list the Purchase Orders created per supplier instead of opening one
Opening one of several created orders hid the rest and moved the buyer off the
Material Request. The created orders are now reported the way Production Plan
reports its documents, as links in a message, and the form stays put.

(cherry picked from commit 6f22551aae)
2026-08-02 06:26:22 +00:00
Mihir Kandoi
d718110216 test: Required By on Purchase Orders created per supplier
Backdates the Material Request item so the mapper drops its schedule date, and
asserts the created order still saves with today as Required By.

(cherry picked from commit 15d10bbaf1)
2026-08-02 06:26:21 +00:00
Mihir Kandoi
f99d66d578 fix: set Required By on Purchase Orders created per supplier
Mapping drops a schedule date that already passed, leaving the buyer to pick a
new one on the Purchase Order form. Nothing fills it in when the orders are
created straight from the supplier selection dialog, so a Material Request
whose required date has gone by failed to save with "Please enter the Required
By".

Items that lose their date now fall back to today, which is the earliest date a
Purchase Order raised today accepts.

(cherry picked from commit d05bd80b1e)
2026-08-02 06:26:21 +00:00
Mihir Kandoi
f19aa957cb test: quantity handling in the supplier selection dialog
Asserts the requested quantity reaches the Purchase Order item and that rows
without a supplier, or with a quantity that is zero, negative or beyond the
pending quantity, are rejected.

(cherry picked from commit 09cfd1fe91)
2026-08-02 06:26:21 +00:00
Mihir Kandoi
2c8c375ca6 feat: adjust the ordered quantity in the supplier selection dialog
The dialog prefilled the pending quantity of each Material Request item but
kept it read only, so ordering less than what was requested meant editing the
Purchase Order afterwards.

The quantity is now editable and is validated against the pending quantity of
its Material Request item, both in the dialog and on the server. The requested
quantity is handed to the mapper as the pending quantity of the source row, so
the existing mapping - including the subcontracting conversions - derives the
Purchase Order quantities from it unchanged.

(cherry picked from commit da83370c5c)
2026-08-02 06:26:20 +00:00
Mihir Kandoi
f4d3b2771b test: supplier selection when creating Purchase Orders from Material Request
Covers the default supplier lookup for pending items, the supplier passed
through to a single mapped order, the grouping of items into one order per
supplier, and the failure when an item is sent without a supplier.

(cherry picked from commit 65be201ed6)

# Conflicts:
#	erpnext/stock/doctype/material_request/test_material_request.py
2026-08-02 06:26:20 +00:00
Mihir Kandoi
95f7810948 feat: select a supplier per item when creating Purchase Orders from Material Request
Creating a Purchase Order from a Material Request mapped every pending item
into a single order, leaving the buyer to split it by hand whenever the items
came from different vendors.

The Create action now reads the default supplier of each pending item (item,
item group, then brand defaults). When the items resolve to more than one
distinct supplier - including the case where only some of them have a default -
a dialog lists the items with their default supplier prefilled and editable.
Submitting it groups the items by the chosen supplier and creates one draft
Purchase Order per group.

When every item resolves to the same supplier the order is mapped straight
away with that supplier set, and when none of them has a default supplier the
previous behaviour is unchanged.

(cherry picked from commit e8df7b4a90)

# Conflicts:
#	erpnext/stock/doctype/material_request/mapper.py
#	erpnext/stock/doctype/material_request/material_request.js
2026-08-02 06:26:20 +00:00
Mihir Kandoi
5463bd93aa 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:58 +00:00
Mihir Kandoi
e5999b22c7 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)
2026-08-02 06:25:58 +00:00
Shllokkk
1fbccd9823 Merge pull request #57694 from frappe/mergify/bp/version-16-hotfix/pr-57681
fix: set reservation voucher_qty to voucher demand not reserved qty (backport #57681)
2026-08-01 16:09:20 +05:30
Shllokkk
9457cae327 test: partial work order reservation records full voucher_qty
(cherry picked from commit 7a97dc3361)
2026-08-01 10:12:50 +00:00
Shllokkk
ecccedf0ed fix: set reservation voucher_qty to voucher demand not reserved qty
(cherry picked from commit 7995bb9960)
2026-08-01 10:12:50 +00:00
Diptanil Saha
5633c29223 Merge pull request #57692 from frappe/mergify/bp/version-16-hotfix/pr-57201
fix: permission checks on various whitelisted methods (backport #57201)
2026-08-01 15:12:53 +05:30
diptanilsaha
fcbbb251cf fix(payment_request): added permission checks on resend_payment_email
(cherry picked from commit 0659bd7049)
2026-08-01 15:01:34 +05:30
diptanilsaha
c7cf9d868b fix(item_variant): added permission checks on enqueue_multiple_variant_creation
(cherry picked from commit 3b0cbc972e)
2026-08-01 15:01:27 +05:30
diptanilsaha
99f249b1a0 fix(assets): add permission checks on whitelisted methods on asset_capitalization
(cherry picked from commit 09d721d1be)
2026-08-01 14:55:42 +05:30
Mihir Kandoi
38f7c824f1 Merge pull request #57683 from frappe/mergify/bp/version-16-hotfix/pr-57679
fix: exclude transferred and consumed qty from dashboard reserved stock (backport #57679)
2026-08-01 14:50:56 +05:30
Shllokkk
46317b063a fix: exclude transferred and consumed qty from dashboard reserved stock
(cherry picked from commit 6c36624d91)
2026-08-01 09:03:51 +00:00
Shllokkk
2ce88af3c9 Merge pull request #57672 from frappe/mergify/bp/version-16-hotfix/pr-57668
fix: drop row prefix in reserve stock message when row is unknown (backport #57668)
2026-07-31 23:31:20 +05:30
Shllokkk
684ae4d762 fix: drop row prefix in reserve stock message when row is unknown
(cherry picked from commit 517053bc25)
2026-07-31 22:51:30 +05:30
Sudharsanan Ashok
d09c04a623 fix: update stock variance account logic which defaults to default expense (#57656)
* fix(stock): update stock variance account logic which defaults to default expense account set in company

* test: add regression test for purchase invoice stock adjustment account fallback

---------

Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
2026-07-31 22:10:15 +05:30
mergify[bot]
624a236f88 fix(quotation): carry forward communications from opportunity at after_insert (backport #57639) (#57643)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 20:56:21 +05:30
mergify[bot]
7bc04752fc fix(plant_floor): add missing perm check on get_stock_summary (backport #57667) (#57670)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 15:19:50 +00:00
Mihir Kandoi
810da8f542 Merge pull request #57655 from aerele/backport-57567-version-16-hotfix
fix: guard against None row in get_stock_balance_for (backport #57567)
2026-07-31 18:56:42 +05:30
pandiyan
283ee6e07b 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 a
hotfix branch.
2026-07-31 18:11:02 +05:30
Mihir Kandoi
e3d8336213 Merge pull request #57658 from aerele/fix/material-transfer-qty-precision-v16
fix: respect quantity precision in material transfer validation
2026-07-31 17:59:46 +05:30
mergify[bot]
5595d1ed2f fix: use payment entry posting date for received amount exchange rate (backport #57660) (#57663)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 12:29:38 +00:00
mergify[bot]
ebba4e9958 feat: auto-fill subscription accounting dimensions from plan with item fallback (backport #57615) (#57622)
* 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/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 16:03:23 +05:30
Sudharsanan11
59bb56aa8d test: cover material transfer quantity precision 2026-07-31 14:41:53 +05:30
Sudharsanan11
eb969a5866 fix: respect quantity precision in material transfer validation 2026-07-31 14:41:53 +05:30
mergify[bot]
9b452f12b7 fix(accounts receivable): made territory field multi select (backport #57322) (#57346)
fix(accounts receivable): made territory field multi select (#57322)

(cherry picked from commit 1029cd988a)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-31 14:41:15 +05:30
mergify[bot]
c0ac8aaf86 fix(stock): value batched packed-item returns from the original bundle (backport #57327) (#57511)
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-31 12:17:54 +05:30
mergify[bot]
0e46937f60 fix: filter Accounts Receivable by invoice sales partner (backport #57628) (#57648)
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:06:36 +05:30
Shllokkk
0c0350110e Merge pull request #57557 from frappe/mergify/bp/version-16-hotfix/pr-57552
fix: respect child warehouse account override in Stock and Account Value Comparison (backport #57552)
2026-07-31 11:57:21 +05:30
Mihir Kandoi
a620648471 Merge pull request #57243 from frappe/mergify/bp/version-16-hotfix/pr-57223
fix(projects): include on hold status in project filters and reports (backport #57223)
2026-07-31 11:50:06 +05:30
Mihir Kandoi
5a75be871a Merge pull request #57638 from frappe/mergify/bp/version-16-hotfix/pr-57606
fix: guard scio row lookup in stock entry items_add (backport #57606)
2026-07-31 11:48:49 +05:30
Poovetha
3ba83134af fix(projects): add project filter
(cherry picked from commit 7248961568)
2026-07-31 11:07:35 +05:30
Poovetha
76fce556c8 test(projects): add test to ensure on hold project retains status
(cherry picked from commit 79e5ccd370)
2026-07-31 11:07:35 +05:30
Poovetha
d104d8e723 fix(projects): include on hold status in project filters and reports
(cherry picked from commit 51a9fc0316)
2026-07-31 11:07:35 +05:30
ruthra kumar
249acdd7e2 Merge pull request #57641 from frappe/mergify/bp/version-16-hotfix/pr-57434
fix: update doc status in period closing voucher (backport #57434)
2026-07-31 10:48:14 +05:30
nareshkannasln
17aeb0b55b fix: validate account frozen date
(cherry picked from commit b3c2ba5381)
2026-07-31 05:05:52 +00:00
mergify[bot]
2d03d80269 feat: status based bar colors in Work Order gantt view (backport #57634) (#57636)
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:58:54 +05:30
pandiyan
fb6c87dd0c fix: guard scio row lookup in stock entry items_add
check the result of find() before reading t_warehouse off it. on a
'receive from customer' entry with no row carrying scio_detail, find()
returns undefined and items_add throws a typeerror.

the throw rejects the serially-run handler chain, so the stock entry
controller's own items_add never runs and the new row silently loses
its target warehouse, expense account, cost center and serial/batch
field defaults.

leave t_warehouse unset when no reference row exists, so the rest of
the chain still runs.

(cherry picked from commit 6e444a1832)
2026-07-31 03:26:57 +00:00
mergify[bot]
34cbd3c8d5 fix: do not fetch a random inventory account when multiple inventory accounts exist (backport #57626) (#57632)
* fix: do not fetch a random inventory account when multiple inventory accounts exist (#57626)

(cherry picked from commit 386a4ac1f0)

# Conflicts:
#	erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py

* chore: fix conflicts

Remove redundant inter-company transaction tests and related setup.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-30 23:25:18 +05:30
Mihir Kandoi
9c86f98e5f Merge pull request #57630 from frappe/mergify/bp/version-16-hotfix/pr-57616
fix: seed standard Item Groups under the existing tree root (backport #57616)
2026-07-30 19:39:13 +05:30
Mihir Kandoi
d1d214ddee chore: fix import order in item group tests 2026-07-30 19:18:07 +05:30
Mihir Kandoi
4ffa950aa3 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)
2026-07-30 13:42:04 +00:00
Shllokkk
43fd439866 Merge pull request #57620 from frappe/mergify/bp/version-16-hotfix/pr-57618
fix: source manually created asset value from valuation rate (backport #57618)
2026-07-30 15:21:11 +05:30
Shllokkk
aa60192ab7 refactor: add type-hints for get_values_from_purchase_doc in asset 2026-07-30 15:03:04 +05:30
Shllokkk
b556b012f0 fix: source manually created asset value from valuation rate
(cherry picked from commit 46e01c2d92)
2026-07-30 09:23:30 +00:00
mergify[bot]
49924ddd1d fix(stock): keep manufactured item rate at zero when inputs are free (backport #57334) (#57513)
fix(stock): keep manufactured item rate at zero when inputs are free  (#57334)

* fix(stock): keep manufactured item rate at zero when inputs are free

when a finished item is produced from raw materials consumed at zero
valuation, the incoming rate fell back to the item's own valuation
rate (or BOM cost), valuing free inputs as output and inflating the fg
value on every production run.

add has_consumption_basis() to detect when the consumed cost is known
even if it is zero (consumed rows present, or a consumption entry
exists for the work order). when it is, skip the get_valuation_rate and
BOM-cost fallbacks so a real cost of zero is preserved.

* test(stock): cover manufacture rate for zero-valued raw materials

- manufacture from a free input keeps fg basic_rate and sle
  incoming_rate/stock_value_difference at zero even when the fg already
  carries a valuation in the target warehouse
- material consumption on with no consumption entry does not fall back
  to bom/price-list rate for free inputs
- zero-valued consumption entry keeps the manufacture entry's fg rate
  at zero

(cherry picked from commit 73224d3650)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-30 09:25:35 +05:30
mergify[bot]
1ea1ce15d0 fix(accounts): update AU standard chart of accounts (backport #57145) (#57608)
fix(accounts): update AU standard chart of accounts (#57145)


(cherry picked from commit fee3a6e0fd)

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
Co-authored-by: Jebajebas <jeba.j@arus.co.in>
2026-07-30 02:32:38 +00:00
Mihir Kandoi
04e1ca8226 fix(selling): don't require cancel and delete perms to remove items via Update Items (backport #57419) (#57601)
Row removal called cancel() and delete() on the child row, and both check
permissions against the parent doctype. Dropping a row therefore needed Cancel
and Delete on the order, while the rest of the dialog only needs Write: the
button is gated on has_perm("write"), update_child_qty_rate checks parent
Write, and edits save with ignore_permissions=True.

Set ignore_permissions on the row before cancel/delete so removal sits behind
the same parent Write check as add and edit. validate_child_on_delete is
unchanged, so rows with ordered, received, delivered or billed qty are still
refused.

On version-16-hotfix validate_and_delete_children still lives in
erpnext/controllers/accounts_controller.py, not the extracted
erpnext/accounts/services/child_item_update.py module it was moved to on
develop.

Co-authored-by: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com>
2026-07-29 11:30:36 +00:00
mergify[bot]
16be0f0944 fix: let Purchase Receipt cancel defer to Frappe's linked-document check (backport #57592) (#57597)
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-29 15:24:08 +05:30
Krishna Pramod Shirsath
7daa1dacc3 Merge pull request #57590 from frappe/mergify/bp/version-16-hotfix/pr-57314
fix(italy): skip e-invoicing for opening invoices (backport #57314)
2026-07-29 13:43:52 +05:30
mergify[bot]
87735b1f68 refactor(accounts): repost accounting ledger (backport #56442) (#57585)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-29 13:06:18 +05:30
Krishna Shirsath
5e584d1cfb fix(italy): skip e-invoicing for opening invoices
(cherry picked from commit f328018bfb)
2026-07-29 05:39:39 +00:00
Mihir Kandoi
123e205bbd Merge pull request #57559 from aerele/backport-57335-version-16-hotfix
refactor: reuse shared date range validation across reports
2026-07-29 07:54:46 +05:30
pandiyan
b432a10222 refactor: reuse shared date range validation across reports 2026-07-28 17:29:40 +05:30
Shllokkk
ca657d2629 fix: respect child warehouse account override in Stock and Account Value Comparison (#57552)
fix: respect child warehouse account override in stock vs account value comparison
(cherry picked from commit 5fc20d6b8e)
2026-07-28 11:22:41 +00:00
114 changed files with 154247 additions and 67207 deletions

View File

@@ -24,7 +24,8 @@
"account_number": "11530"
},
"account_number": "115",
"is_group": 1
"is_group": 1,
"account_type": "Bank"
},
"Trade Receivables": {
"Trade Debtors": {
@@ -529,6 +530,13 @@
"account_number": "630",
"is_group": 1
},
"Accrued Manufacturing Expenses": {
"Accrued Expenses - Manufacturing": {
"account_number": "63510"
},
"account_number": "635",
"is_group": 1
},
"account_number": "63",
"is_group": 1
},
@@ -814,4 +822,4 @@
"root_type": "Expense"
}
}
}
}

View File

@@ -3326,13 +3326,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

@@ -37,6 +37,8 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
frm.set_intro(__("Failure: {0}", [frm.doc.failed_reason]), "red");
}
let sending_email = false;
if (
frm.doc.payment_request_type == "Inward" &&
frm.doc.payment_channel !== "Phone" &&
@@ -45,16 +47,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

@@ -423,6 +423,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 = {
@@ -440,11 +452,14 @@ class PaymentRequest(Document):
)
],
}
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,
)
@@ -951,11 +966,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

@@ -1380,7 +1380,20 @@ class PurchaseInvoice(BuyingController):
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
@@ -1405,7 +1418,20 @@ class PurchaseInvoice(BuyingController):
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
stock_adjustment_amt = warehouse_debit_amount - stock_amount

View File

@@ -1490,6 +1490,96 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account)
def test_stock_adjustment_account_fallbacks_when_default_expense_account_unset(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice
class StockAdjustmentInvoice:
company = "_Test Company"
conversion_rate = 1
update_stock = 1
is_internal_supplier = 0
return_against = None
project = None
def __init__(self, is_return, defaults):
self.is_return = is_return
self.defaults = defaults
def get(self, fieldname):
return None
def get_company_default(self, fieldname, ignore_validation=False):
return self.defaults.get(fieldname)
def get_gl_dict(self, args, *unused_args, **unused_kwargs):
return frappe._dict(args)
def make_invoice(is_return, defaults):
return StockAdjustmentInvoice(is_return, defaults)
def make_item(is_fixed_asset=0, expense_account="Item Expense - _TC"):
return frappe._dict(
{
"name": "row-1",
"warehouse": "Stores - _TC",
"valuation_rate": 10,
"qty": 10,
"conversion_factor": 1,
"base_net_amount": 100,
"item_tax_amount": 0,
"landed_cost_voucher_amount": 0,
"sales_incoming_rate": 0,
"is_fixed_asset": is_fixed_asset,
"expense_account": expense_account,
"cost_center": "Main - _TC",
"project": None,
"precision": lambda fieldname: 2,
}
)
defaults = {
"default_expense_account": None,
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
"asset_received_but_not_billed": "Asset Received But Not Billed - _TC",
}
test_cases = (
(
"company default expense",
0,
make_item(),
{**defaults, "default_expense_account": "Default Expense - _TC"},
"Default Expense - _TC",
),
("stock rbnb", 0, make_item(), defaults, "Stock Received But Not Billed - _TC"),
(
"asset rbnb",
0,
make_item(is_fixed_asset=1),
defaults,
"Asset Received But Not Billed - _TC",
),
("return item expense", 1, make_item(), defaults, "Item Expense - _TC"),
(
"return without item expense",
1,
make_item(expense_account=None),
defaults,
"Stock Received But Not Billed - _TC",
),
)
for label, is_return, item, company_defaults, expected_account in test_cases:
with self.subTest(label=label):
invoice = make_invoice(is_return, company_defaults)
gl_entries = []
PurchaseInvoice.make_stock_adjustment_entry(
invoice, gl_entries, item, {(item.name, item.warehouse): 90}, "INR"
)
self.assertEqual(gl_entries[0].account, expected_account)
self.assertEqual(gl_entries[0].debit, 10)
self.assertEqual(gl_entries[0].debit_in_transaction_currency, 10)
@ERPNextTestSuite.change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry

View File

@@ -22,27 +22,50 @@ frappe.ui.form.on("Repost Accounting Ledger", {
},
refresh: function (frm) {
frm.add_custom_button(__("Show Preview"), () => {
frm.call({
method: "generate_preview",
doc: frm.doc,
freeze: true,
freeze_message: __("Generating Preview"),
callback: function (r) {
if (r && r.message) {
let content = r.message;
let opts = {
title: "Preview",
subtitle: "preview",
content: content,
print_settings: { orientation: "landscape" },
columns: [],
data: [],
};
frappe.render_grid(opts);
}
},
// the server refuses only while the job is alive, so a dead one can be restarted here
if (frm.doc.docstatus == 1 && !["Completed", "Cancelled"].includes(frm.doc.status)) {
frm.add_custom_button(__("Start Reposting"), () => {
frm.events.start_repost(frm);
});
}
if (frm.doc.docstatus != 2) {
frm.add_custom_button(__("Show Preview"), () => {
frm.events.generate_preview(frm);
});
}
},
generate_preview: function (frm) {
frm.call({
method: "generate_preview",
doc: frm.doc,
freeze: true,
freeze_message: __("Generating Preview"),
callback: function (r) {
if (r && r.message) {
let content = r.message;
let opts = {
title: "Preview",
subtitle: "preview",
content: content,
print_settings: { orientation: "landscape" },
columns: [],
data: [],
};
frappe.render_grid(opts);
}
},
});
},
start_repost: function (frm) {
frm.call({
method: "start_repost",
doc: frm.doc,
callback: function (r) {
frm.reload_doc();
},
});
},
});

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2023-07-04 13:07:32.923675",
"default_view": "List",
"doctype": "DocType",
@@ -7,16 +8,24 @@
"engine": "InnoDB",
"field_order": [
"company",
"column_break_vpup",
"delete_cancelled_entries",
"column_break_vpup",
"status",
"section_break_metl",
"vouchers",
"amended_from"
"error_section",
"error_log",
"miscellaneous_section",
"amended_from",
"column_break_hrah",
"scheduled_job"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company",
"options": "Company"
},
@@ -48,12 +57,54 @@
"fieldname": "delete_cancelled_entries",
"fieldtype": "Check",
"label": "Delete Cancelled Ledger Entries"
},
{
"fieldname": "error_section",
"fieldtype": "Section Break",
"label": "Error"
},
{
"fieldname": "error_log",
"fieldtype": "Code",
"label": "Error Log",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "miscellaneous_section",
"fieldtype": "Section Break",
"label": "Miscellaneous"
},
{
"fieldname": "column_break_hrah",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.docstatus >= 1;",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "\nQueued\nIn Progress\nPartially Reposted\nCompleted\nFailed\nCancelled",
"read_only": 1
},
{
"fieldname": "scheduled_job",
"fieldtype": "Link",
"hidden": 1,
"label": "Scheduled Job",
"no_copy": 1,
"options": "RQ Job",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-06-03 17:30:37.012593",
"modified": "2026-07-28 00:56:50.290314",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger",
@@ -76,8 +127,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -7,9 +7,14 @@ import frappe
from frappe import _, qb
from frappe.desk.form.linked_with import get_child_tables_of_doctypes
from frappe.model.document import Document
from frappe.utils.background_jobs import create_job_id, is_job_enqueued
from frappe.utils.data import comma_and
from frappe.utils.scheduler import is_scheduler_inactive
from erpnext.stock import get_warehouse_account_map
# a batch has to finish well within the timeout of the job reposting it
MAX_VOUCHERS_PER_REPOST = 50
HANDLED_VOUCHER_STATUSES = ("Reposted", "Skipped")
class RepostAccountingLedger(Document):
@@ -28,6 +33,11 @@ class RepostAccountingLedger(Document):
amended_from: DF.Link | None
company: DF.Link | None
delete_cancelled_entries: DF.Check
error_log: DF.Code | None
scheduled_job: DF.Link | None
status: DF.Literal[
"", "Queued", "In Progress", "Partially Reposted", "Completed", "Failed", "Cancelled"
]
vouchers: DF.Table[RepostAccountingLedgerItems]
# end: auto-generated types
@@ -37,6 +47,11 @@ class RepostAccountingLedger(Document):
def validate(self):
self.validate_vouchers()
self.validate_repost_preconditions()
def validate_repost_preconditions(self):
"""The checks a repost queued days ago could have outlived, re-run before it touches
the ledger. Vouchers cancelled since are skipped one by one while reposting."""
self.validate_for_closed_fiscal_year()
self.validate_for_deferred_accounting()
@@ -73,8 +88,52 @@ class RepostAccountingLedger(Document):
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
def validate_vouchers(self):
if self.vouchers:
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
if not self.vouchers:
frappe.throw(_("Add atleast one voucher to repost."))
if len(self.vouchers) > MAX_VOUCHERS_PER_REPOST:
frappe.throw(
_("Cannot repost more than {0} vouchers at once. Split them into multiple documents.").format(
MAX_VOUCHERS_PER_REPOST
)
)
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
self.validate_no_duplicate_vouchers()
self.validate_vouchers_are_submitted()
def validate_no_duplicate_vouchers(self):
vouchers = [(x.voucher_type, x.voucher_no) for x in self.vouchers]
if len(vouchers) != len(set(vouchers)):
frappe.throw(_("Duplicate vouchers found. Remove the duplicate vouchers to continue to repost."))
def validate_vouchers_are_submitted(self):
voucher_type_wise_map = {}
for d in self.vouchers:
voucher_type_wise_map.setdefault(d.voucher_type, [])
voucher_type_wise_map[d.voucher_type].append(d.voucher_no)
non_submitted_vouchers = []
for key in voucher_type_wise_map.keys():
non_submitted_vouchers.extend(
frappe.get_all(
key,
filters={"name": ["in", voucher_type_wise_map[key]], "docstatus": ["!=", 1]},
pluck="name",
)
)
if non_submitted_vouchers:
frappe.throw(
_("The following vouchers are not submitted: {0}").format(
comma_and(non_submitted_vouchers, add_quotes=True)
)
)
def on_discard(self):
self.db_set("status", "Cancelled")
def get_existing_ledger_entries(self):
vouchers = [x.voucher_no for x in self.vouchers]
@@ -139,80 +198,245 @@ class RepostAccountingLedger(Document):
return rendered_page
def on_submit(self):
if len(self.vouchers) > 5:
job_name = "repost_accounting_ledger_" + self.name
frappe.enqueue(
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
account_repost_doc=self.name,
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
self.start_repost()
def before_cancel(self):
self._raise_error_if_reposting_in_progress()
def on_cancel(self):
self.db_set("status", "Cancelled")
def _raise_error_if_reposting_in_progress(self):
if self.scheduled_job and is_job_enqueued(_repost_job_id(self.name)):
frappe.throw(_("Reposting is still in progress in background."))
@frappe.whitelist()
def start_repost(self):
if self.docstatus != 1:
frappe.throw(_("Reposting can be started only for submitted document."))
# under a row lock, so two concurrent starts cannot both get past here
status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True)
if status in ("Completed", "Cancelled"):
frappe.throw(_("Reposting cannot be started when status is {0}.").format(status))
# `Queued` and `In Progress` are held back by the job, not by the status: a worker that
# died leaves the status behind and the document has to stay restartable
self._raise_error_if_reposting_in_progress()
self.check_permission("write")
# workers pick up enqueued jobs whether or not the scheduler runs, so this is a warning
if is_scheduler_inactive():
frappe.msgprint(
_("Scheduler is inactive. Reposting will only run once background jobs are processed."),
alert=True,
indicator="orange",
)
frappe.msgprint(_("Repost has started in the background"))
else:
start_repost(self.name)
self.db_set({"status": "Queued", "scheduled_job": create_job_id(_repost_job_id(self.name))})
_enqueue_repost(self.name)
frappe.msgprint(_("Repost has started in the background"), alert=True, indicator="blue")
@frappe.whitelist()
def start_repost(account_repost_doc: str | None = None) -> None:
from erpnext.accounts.general_ledger import make_reverse_gl_entries
def _repost_job_id(repost_doc_name: str) -> str:
"""Derived from the document, so a repost can only ever have one job."""
return f"repost_accounting_ledger::{repost_doc_name}"
def _enqueue_repost(repost_doc_name: str) -> None:
"""Hand the repost to a background worker.
Tests run it in the foreground, inside their own transaction: documents edited after submit
repost themselves through `repost_accounting_entries`, and tests across apps assert on the
ledger right after doing so.
"""
frappe.enqueue(
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.repost",
repost_doc_name=repost_doc_name,
commit=not frappe.in_test,
queue="long",
timeout=1500,
job_id=_repost_job_id(repost_doc_name),
deduplicate=True,
enqueue_after_commit=True,
now=frappe.in_test,
)
def _lock_vouchers(vouchers) -> dict:
"""Lock every voucher up front so a concurrent repost cannot touch the same GL entries.
Returns them keyed by voucher, so reposting does not load them again. These are file locks
under the site directory: they serialise nothing across hosts that do not share it, and a
worker killed outright leaves them behind until they expire.
"""
locked_docs = {}
try:
for x in vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
doc.lock()
locked_docs[(x.voucher_type, x.voucher_no)] = doc
except Exception:
for doc in locked_docs.values():
doc.unlock()
raise
return locked_docs
def repost(repost_doc_name: str, commit: bool = True):
"""Repost every voucher of the document, one transaction at a time.
`commit` says whether this call owns the transaction. The background job does, and commits
after every voucher so progress survives a crash; a caller inside its own passes `False`.
"""
from erpnext.accounts.utils import _delete_accounting_ledger_entries, _delete_adv_pl_entries
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
repost_doc.check_permission("write")
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting
repost_doc.validate_for_deferred_accounting()
repost_doc = frappe.get_doc("Repost Accounting Ledger", repost_doc_name)
locked_docs = {}
for x in repost_doc.vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
try:
repost_doc.validate_repost_preconditions()
# a retry leaves the vouchers it is done with alone: they are not locked, not loaded
# and not reposted again
pending = [x for x in repost_doc.vouchers if x.status not in HANDLED_VOUCHER_STATUSES]
locked_docs = _lock_vouchers(pending)
repost_doc.db_set("status", "In Progress", commit=commit)
for position, x in enumerate(pending, start=1):
frappe.publish_progress(
position * 100 / len(pending),
doctype=repost_doc.doctype,
docname=repost_doc.name,
description=_("Reposting {0} {1}").format(x.voucher_type, x.voucher_no),
)
save_point = "reposting"
frappe.db.savepoint(save_point=save_point)
try:
doc = locked_docs[(x.voucher_type, x.voucher_no)]
if doc.docstatus == 2:
x.db_set({"status": "Skipped", "traceback": ""})
continue
if repost_doc.delete_cancelled_entries:
frappe.db.delete(
"GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
)
frappe.db.delete(
"Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
)
frappe.db.delete(
"Advance Payment Ledger Entry",
filters={"voucher_type": doc.doctype, "voucher_no": doc.name},
)
_delete_accounting_ledger_entries(doc.doctype, doc.name)
_delete_adv_pl_entries(doc.doctype, doc.name)
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel(from_repost=True)
_repost_vouchers(doc, repost_doc.delete_cancelled_entries)
except Exception:
frappe.db.rollback(save_point=save_point)
doc.docstatus = 1
if doc.doctype == "Sales Invoice":
doc.force_set_against_income_account()
else:
doc.force_set_against_expense_account()
doc.make_gl_entries()
x.db_set({"status": "Failed", "traceback": frappe.get_traceback()})
else:
x.db_set({"status": "Reposted", "traceback": ""})
finally:
if commit:
frappe.db.commit() # nosemgrep
elif doc.doctype == "Purchase Receipt":
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel(from_repost=True)
except Exception:
if commit:
frappe.db.rollback()
doc.docstatus = 1
doc.make_gl_entries(from_repost=True)
_record_repost_failure(repost_doc, commit=commit)
raise
else:
repost_doc.db_set({"status": _derive_status(repost_doc), "error_log": ""}, notify=True)
finally:
for doc in locked_docs.values():
doc.unlock()
if commit:
frappe.db.commit() # nosemgrep
elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
if not repost_doc.delete_cancelled_entries:
doc.make_gl_entries(1)
doc.make_gl_entries()
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
if hasattr(doc, "make_gl_entries") and callable(doc.make_gl_entries):
if not repost_doc.delete_cancelled_entries:
if "cancel" in inspect.getfullargspec(doc.make_gl_entries):
doc.make_gl_entries(cancel=1)
else:
make_reverse_gl_entries(voucher_type=doc.doctype, voucher_no=doc.name)
doc.make_gl_entries()
def _derive_status(repost_doc) -> str:
"""Vouchers are committed one by one, so the status follows what was actually handled."""
handled = sum(1 for voucher in repost_doc.vouchers if voucher.status in HANDLED_VOUCHER_STATUSES)
if handled == len(repost_doc.vouchers):
return "Completed"
elif handled == 0:
return "Failed"
return "Partially Reposted"
def _record_repost_failure(repost_doc, commit=False) -> None:
"""Persist the traceback of a run that could not finish, without discarding its progress."""
# the traceback with frame locals goes to the Error Log, which is permissioned separately
traceback = frappe.get_traceback()
frappe.log_error(
title=_("Unable to Repost Accounting Ledger"),
reference_doctype=repost_doc.doctype,
reference_name=repost_doc.name,
)
frappe.db.set_value(
repost_doc.doctype, repost_doc.name, {"error_log": traceback, "status": _derive_status(repost_doc)}
)
if commit:
frappe.db.commit()
def _repost_vouchers(doc, delete_cancelled_entries: bool | int | None):
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
_repost_invoices(doc, delete_cancelled_entries)
elif doc.doctype == "Purchase Receipt":
_repost_purchase_receipt(doc, delete_cancelled_entries)
elif doc.doctype in ["Payment Entry", "Journal Entry"]:
_repost_pe_je(doc, delete_cancelled_entries)
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries)
def _repost_invoices(invoice_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
invoice_doc.docstatus = 2
invoice_doc.make_gl_entries_on_cancel(from_repost=True)
invoice_doc.docstatus = 1
if invoice_doc.doctype == "Sales Invoice":
invoice_doc.force_set_against_income_account()
else:
invoice_doc.force_set_against_expense_account()
invoice_doc.make_gl_entries()
def _repost_purchase_receipt(receipt_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
receipt_doc.docstatus = 2
receipt_doc.make_gl_entries_on_cancel(from_repost=True)
receipt_doc.docstatus = 1
receipt_doc.make_gl_entries(from_repost=True)
def _repost_pe_je(entry_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
entry_doc.make_gl_entries(cancel=1)
entry_doc.make_gl_entries()
def _repost_allowed_hook_doctypes(repost_doc, delete_cancelled_entries: bool | int | None):
from erpnext.accounts.general_ledger import make_reverse_gl_entries
if hasattr(repost_doc, "make_gl_entries") and callable(repost_doc.make_gl_entries):
if not delete_cancelled_entries:
if "cancel" in inspect.getfullargspec(repost_doc.make_gl_entries).args:
repost_doc.make_gl_entries(cancel=1)
else:
make_reverse_gl_entries(voucher_type=repost_doc.doctype, voucher_no=repost_doc.name)
repost_doc.make_gl_entries()
def get_allowed_types_from_settings(child_doc: bool = False):

View File

@@ -0,0 +1,16 @@
frappe.listview_settings["Repost Accounting Ledger"] = {
add_fields: ["status"],
// drafts and cancelled documents are coloured by the framework before it gets here
get_indicator: function (doc) {
if (!doc.status) return;
const status_color = {
Queued: "yellow",
"In Progress": "blue",
"Partially Reposted": "orange",
Completed: "green",
Failed: "red",
};
return [__(doc.status), status_color[doc.status] || "gray", "status,=," + doc.status];
},
};

View File

@@ -1,27 +1,42 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from contextlib import contextmanager
from unittest.mock import patch
import frappe
from frappe import qb
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, nowdate, today
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
_lock_vouchers,
_record_repost_failure,
_repost_allowed_hook_doctypes,
_repost_job_id,
_repost_vouchers,
repost,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.utils import get_fiscal_year
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
from erpnext.tests.utils import ERPNextTestSuite
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
SIMULATED_FAILURE = "Simulated repost failure"
class TestRepostAccountingLedger(ERPNextTestSuite):
def setUp(self):
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
update_repost_settings()
def test_01_basic_functions(self):
si = create_sales_invoice(
def make_invoice(self, **kwargs):
return create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
@@ -29,8 +44,71 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
**kwargs,
)
def make_invoice_and_payment(self):
si = self.make_invoice()
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
return si, pe
def create_repost_doc(self, vouchers, delete_cancelled_entries=False, submit=False):
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.delete_cancelled_entries = delete_cancelled_entries
for voucher in vouchers:
ral.append("vouchers", {"voucher_type": voucher.doctype, "voucher_no": voucher.name})
ral.save()
if submit:
ral.submit()
ral.reload()
return ral
@contextmanager
def patched_repost(self, fail_for=()):
"""Yield the vouchers handed over to `_repost_vouchers`, failing the given types."""
reposted = []
def repost_voucher(doc, delete_cancelled_entries):
reposted.append(doc.name)
if doc.doctype in fail_for:
frappe.throw(SIMULATED_FAILURE)
_repost_vouchers(doc, delete_cancelled_entries)
with patch(f"{REPOST_MODULE}._repost_vouchers", new=repost_voucher):
yield reposted
def make_period_closing_voucher(self):
fy = get_fiscal_year(today(), company="_Test Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": "_Test Company",
"fiscal_year": fy[0],
"cost_center": "Main - _TC",
"closing_account_head": "Retained Earnings - _TC",
"remarks": "test",
}
)
return pcv.save().submit()
def get_gl_totals(self, voucher_no, is_cancelled=0):
gl = qb.DocType("GL Entry")
return (
qb.from_(gl)
.select(Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == voucher_no) & (gl.is_cancelled == is_cancelled))
.run()
)[0]
def test_01_basic_functions(self):
si = self.make_invoice()
preq = frappe.get_doc(
make_payment_request(
dt=si.doctype,
@@ -64,51 +142,24 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": "Debtors - _TC"})
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
gl = qb.DocType("GL Entry")
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.run()
)
# Assert incorrect ledger balance
self.assertNotEqual(res[0], (si.name, 100, 100))
self.assertNotEqual(self.get_gl_totals(si.name), (100, 100))
# Submit repost document
ral.save().submit()
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.run()
)
# Ledger should reflect correct amount post repost
self.assertEqual(res[0], (si.name, 100, 100))
self.assertEqual(self.get_gl_totals(si.name), (100, 100))
def test_02_deferred_accounting_valiations(self):
si = create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
do_not_submit=True,
)
si = self.make_invoice(do_not_submit=True)
si.items[0].enable_deferred_revenue = True
si.items[0].deferred_revenue_account = "Deferred Revenue - _TC"
si.items[0].service_start_date = nowdate()
si.items[0].service_end_date = add_days(nowdate(), 90)
si.save().submit()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_04_pcv_validation(self):
@@ -116,86 +167,29 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
gl = frappe.qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
si = create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
fy = get_fiscal_year(today(), company="_Test Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": "_Test Company",
"fiscal_year": fy[0],
"cost_center": "Main - _TC",
"closing_account_head": "Retained Earnings - _TC",
"remarks": "test",
}
)
pcv.save().submit()
si = self.make_invoice()
pcv = self.make_period_closing_voucher()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
pcv.reload()
pcv.cancel()
pcv.delete()
def test_03_deletion_flag_and_preview_function(self):
si = create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
si, pe = self.make_invoice_and_payment()
# with deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save().submit()
self.create_repost_doc([si, pe], delete_cancelled_entries=True, submit=True)
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
def test_05_without_deletion_flag(self):
si = create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
si, pe = self.make_invoice_and_payment()
# without deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.delete_cancelled_entries = False
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save().submit()
self.create_repost_doc([si, pe], submit=True)
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
@@ -246,11 +240,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
another_provisional_account,
)
repost_doc = frappe.new_doc("Repost Accounting Ledger")
repost_doc.company = "_Test Company"
repost_doc.delete_cancelled_entries = True
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
repost_doc.save().submit()
repost_doc = self.create_repost_doc([pr], delete_cancelled_entries=True, submit=True)
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
expected_pr_gles_after_repost = [
@@ -271,6 +261,281 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
company.default_provisional_account = None
company.save()
def test_07_voucher_validations(self):
submitted_si = self.make_invoice()
draft_si = self.make_invoice(do_not_submit=True)
cancelled_si = self.make_invoice()
cancelled_si.cancel()
for vouchers, exception, message in (
([], frappe.ValidationError, "Add atleast one voucher"),
([submitted_si, submitted_si], frappe.ValidationError, "Duplicate vouchers found"),
([draft_si], frappe.ValidationError, f"not submitted.*{draft_si.name}"),
# cancelled vouchers don't make it past link validation
([cancelled_si], frappe.CancelledLinkError, "Cannot link cancelled document"),
):
with self.subTest(vouchers=[x.name for x in vouchers]):
self.assertRaisesRegex(exception, message, self.create_repost_doc, vouchers)
self.create_repost_doc([submitted_si])
def test_08_voucher_count_limit(self):
si, pe = self.make_invoice_and_payment()
another_si = self.make_invoice()
with patch(f"{REPOST_MODULE}.MAX_VOUCHERS_PER_REPOST", 2):
self.create_repost_doc([si, pe])
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot repost more than 2 vouchers",
self.create_repost_doc,
[si, pe, another_si],
)
def test_09_status_lifecycle(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
self.assertEqual(ral.status, "")
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Completed")
self.assertFalse(ral.error_log)
for voucher in ral.vouchers:
self.assertEqual(voucher.status, "Reposted")
self.assertFalse(voucher.traceback)
ral.cancel()
ral.reload()
self.assertEqual(ral.status, "Cancelled")
discarded = self.create_repost_doc([si])
discarded.discard()
discarded.reload()
self.assertEqual(discarded.status, "Cancelled")
def test_10_start_repost_guards(self):
si = self.make_invoice()
ral = self.create_repost_doc([si])
self.assertRaisesRegex(frappe.ValidationError, "only for submitted document", ral.start_repost)
ral.submit()
ral.reload()
self.assertRaisesRegex(
frappe.ValidationError, "cannot be started when status is Completed", ral.start_repost
)
# a document left behind by a worker that died mid-repost
ral.db_set("status", "In Progress")
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=True):
self.assertRaisesRegex(
frappe.ValidationError, "still in progress in background", ral.start_repost
)
self.assertRaisesRegex(frappe.ValidationError, "still in progress in background", ral.cancel)
# `cancel` flips docstatus in memory before running `before_cancel`
ral.reload()
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=False):
# the job is gone, so `In Progress` must not keep the document stuck
ral.start_repost()
ral.reload()
self.assertEqual(ral.status, "Completed")
def test_11_repost_job_is_tied_to_the_document(self):
si = self.make_invoice()
ral = self.create_repost_doc([si], submit=True)
ral.db_set("status", "Failed")
with patch(f"{REPOST_MODULE}.frappe.enqueue") as enqueue:
ral.start_repost()
kwargs = enqueue.call_args.kwargs
self.assertEqual(kwargs["repost_doc_name"], ral.name)
self.assertEqual(kwargs["job_id"], _repost_job_id(ral.name))
# a second start cannot queue a second job for the same document
self.assertTrue(kwargs["deduplicate"])
def test_12_voucher_failures_are_isolated_and_retried(self):
si, pe = self.make_invoice_and_payment()
pe_gl_entries = frappe.db.count("GL Entry", {"voucher_no": pe.name})
# the deletion flag drops the existing entries before reposting them
ral = self.create_repost_doc([si, pe], delete_cancelled_entries=True)
with self.patched_repost(fail_for=["Payment Entry"]):
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Partially Reposted")
si_row, pe_row = ral.vouchers
self.assertEqual((si_row.status, pe_row.status), ("Reposted", "Failed"))
self.assertFalse(si_row.traceback)
self.assertIn(SIMULATED_FAILURE, pe_row.traceback)
# the failed voucher is rolled back to its savepoint, so its entries are back
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pe.name}), pe_gl_entries)
# a retry only picks up the vouchers that are not reposted yet, and leaves the rest
# alone entirely: they are not locked or loaded either
with (
patch(f"{REPOST_MODULE}._lock_vouchers", side_effect=_lock_vouchers) as lock_vouchers,
self.patched_repost() as retried,
):
ral.start_repost()
self.assertEqual(retried, [pe.name])
self.assertEqual([x.voucher_no for x in lock_vouchers.call_args.args[0]], [pe.name])
ral.reload()
self.assertEqual(ral.status, "Completed")
for voucher in ral.vouchers:
self.assertEqual(voucher.status, "Reposted")
self.assertFalse(voucher.traceback)
def test_13_status_of_a_run_that_could_not_finish(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
with self.patched_repost(fail_for=["Payment Entry"]):
ral.submit()
ral.reload()
# the job dies after the loop committed the invoice, e.g. killed or timed out
try:
frappe.throw(SIMULATED_FAILURE)
except frappe.ValidationError:
_record_repost_failure(ral)
ral.reload()
# progress already committed must not be reported as a total failure
self.assertEqual(ral.status, "Partially Reposted")
self.assertIn(SIMULATED_FAILURE, ral.error_log)
self.assertTrue(
frappe.db.exists("Error Log", {"reference_doctype": ral.doctype, "reference_name": ral.name})
)
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_14_period_closed_after_the_repost_was_started(self):
gl = qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
si = self.make_invoice()
ral = self.create_repost_doc([si], submit=True)
ral.db_set("status", "Failed")
ral.vouchers[0].db_set("status", "Pending")
# the period is closed between the repost being started and the job running
self.make_period_closing_voucher()
gl_entries = frappe.db.count("GL Entry", {"voucher_no": si.name})
self.assertRaisesRegex(frappe.ValidationError, "Closed fiscal year", repost, ral.name, commit=False)
ral.reload()
self.assertEqual(ral.status, "Failed")
self.assertIn("Closed fiscal year", ral.error_log)
# the ledger is left exactly as it was
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": si.name}), gl_entries)
self.assertEqual(ral.vouchers[0].status, "Pending")
def test_15_failed_repost_skips_cancelled_voucher(self):
si = self.make_invoice()
ral = self.create_repost_doc([si])
with self.patched_repost(fail_for=["Sales Invoice"]):
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Failed")
si.reload()
si.cancel()
ral.start_repost()
ral.reload()
# nothing was reposted, but there is nothing left to repost either
self.assertEqual(ral.status, "Completed")
self.assertEqual(ral.vouchers[0].status, "Skipped")
self.assertFalse(ral.vouchers[0].traceback)
def test_16_concurrent_repost_is_blocked_by_voucher_lock(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
# a concurrent repost holding the lock on the second voucher
locked_pe = frappe.get_doc(pe.doctype, pe.name)
locked_pe.lock()
try:
self.assertRaises(frappe.DocumentLockedError, ral.submit)
# vouchers locked before the failure are released again
self.assertFalse(frappe.get_doc(si.doctype, si.name).is_locked)
finally:
locked_pe.unlock()
def test_17_journal_entry_repost(self):
je = make_journal_entry("_Test Bank - _TC", "_Test Cash - _TC", 500, submit=True)
je = frappe.get_doc("Journal Entry", je.name)
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
# without the deletion flag the 2 original entries are marked as cancelled,
# along with the 2 reverse entries booked against them
for delete_cancelled_entries, cancelled_entries in ((False, 4), (True, 0)):
with self.subTest(delete_cancelled_entries=delete_cancelled_entries):
ral = self.create_repost_doc(
[je], delete_cancelled_entries=delete_cancelled_entries, submit=True
)
self.assertEqual(ral.status, "Completed")
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
self.assertEqual(
frappe.db.count("GL Entry", {"voucher_no": je.name, "is_cancelled": 1}),
cancelled_entries,
)
def test_18_hook_allowed_doctype_repost(self):
class VoucherWithCancelArg:
doctype = "Test Repost Voucher"
name = "TRV-00001"
def __init__(self):
self.calls = []
def make_gl_entries(self, cancel=0):
self.calls.append(cancel)
class VoucherWithoutCancelArg(VoucherWithCancelArg):
def make_gl_entries(self):
self.calls.append("repost")
# vouchers that can reverse their own entries are asked to do so first
doc = VoucherWithCancelArg()
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
self.assertEqual(doc.calls, [1, 0])
# nothing to reverse when the old entries are deleted
doc = VoucherWithCancelArg()
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=True)
self.assertEqual(doc.calls, [0])
# the rest fall back to the generic reversal
doc = VoucherWithoutCancelArg()
with patch("erpnext.accounts.general_ledger.make_reverse_gl_entries") as make_reverse_gl_entries:
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
make_reverse_gl_entries.assert_called_once_with(voucher_type=doc.doctype, voucher_no=doc.name)
self.assertEqual(doc.calls, ["repost"])
def update_repost_settings():
allowed_types = [

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"creation": "2023-07-04 14:14:01.243848",
"doctype": "DocType",
@@ -7,34 +8,70 @@
"engine": "InnoDB",
"field_order": [
"voucher_type",
"voucher_no"
"column_break_ndex",
"voucher_no",
"reposting_status_section",
"status",
"traceback"
],
"fields": [
{
"columns": 5,
"fieldname": "voucher_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Voucher Type",
"options": "DocType"
"options": "DocType",
"reqd": 1
},
{
"fieldname": "column_break_ndex",
"fieldtype": "Column Break"
},
{
"columns": 5,
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Voucher No",
"options": "voucher_type"
"options": "voucher_type",
"reqd": 1
},
{
"fieldname": "reposting_status_section",
"fieldtype": "Section Break",
"label": "Reposting Status"
},
{
"columns": 2,
"default": "Pending",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"no_copy": 1,
"options": "Pending\nReposted\nSkipped\nFailed",
"read_only": 1
},
{
"fieldname": "traceback",
"fieldtype": "Code",
"label": "Traceback",
"no_copy": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:32.170897",
"modified": "2026-07-29 02:41:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger Items",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -17,8 +17,10 @@ class RepostAccountingLedgerItems(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
voucher_no: DF.DynamicLink | None
voucher_type: DF.Link | None
status: DF.Literal["Pending", "Reposted", "Skipped", "Failed"]
traceback: DF.Code | None
voucher_no: DF.DynamicLink
voucher_type: DF.Link
# end: auto-generated types
pass

View File

@@ -161,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

@@ -25,6 +25,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.stock.doctype.item.item import get_item_defaults
class InvoiceCancelled(frappe.ValidationError):
@@ -801,6 +802,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,12 @@ from frappe.utils.data import (
)
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all
from erpnext.accounts.doctype.subscription.subscription import (
Subscription,
get_plan_dimensions,
get_prorata_factor,
process_all,
)
from erpnext.accounts.utils import update_subscription_on_invoice_update
from erpnext.tests.utils import ERPNextTestSuite
@@ -804,6 +809,48 @@ class TestSubscription(ERPNextTestSuite):
)
self.assertEqual(len(subscription.invoices), 0)
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

@@ -117,8 +117,11 @@ frappe.query_reports["Accounts Payable"] = {
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
fieldtype: "MultiSelectList",
options: "Supplier Group",
get_data: function (txt) {
return frappe.db.get_link_options("Supplier Group", txt);
},
hidden: 1,
},
{

View File

@@ -117,6 +117,36 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
self.assertEqual(len(report[1]), 2)
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
def test_supplier_group_filter(self):
pi = self.create_purchase_invoice()
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
other_group = frappe.get_doc(
doctype="Supplier Group",
supplier_group_name="_Test Supplier Group AP",
parent_supplier_group="All Supplier Groups",
).insert()
filters = {
"company": self.company,
"party_type": "Supplier",
"report_date": today(),
"range": "30, 60, 90, 120",
"supplier_group": supplier_group,
}
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
filters.update({"supplier_group": [other_group.name]})
self.assertEqual(len(execute(filters)[1]), 0)
filters.update({"supplier_group": [supplier_group, other_group.name]})
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
filters.update({"supplier_group": ["All Supplier Groups"]})
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
filters.update({"supplier_group": ["_Test Supplier Group Mars"]})
self.assertRaises(frappe.ValidationError, execute, filters)
def test_project_filter(self):
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}

View File

@@ -100,8 +100,11 @@ frappe.query_reports["Accounts Payable Summary"] = {
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
fieldtype: "MultiSelectList",
options: "Supplier Group",
get_data: function (txt) {
return frappe.db.get_link_options("Supplier Group", txt);
},
},
{
fieldname: "based_on_payment_terms",

View File

@@ -140,8 +140,11 @@ frappe.query_reports["Accounts Receivable"] = {
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "Link",
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "group_by_party",

View File

@@ -108,6 +108,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()
@@ -243,6 +244,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:
@@ -471,7 +478,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)
@@ -909,6 +916,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 = []
@@ -996,7 +1019,13 @@ class ReceivablePayableReport:
self.qb_selection_filter.append(self.ple.party.isin(customers))
if self.filters.get("territory"):
self.get_hierarchical_filters("Territory", "territory")
territories = get_nested_set_children("Territory", self.filters.territory)
customers = (
qb.from_(self.customer)
.select(self.customer.name)
.where(self.customer["territory"].isin(territories))
)
self.qb_selection_filter.append(self.ple.party.isin(customers))
if self.filters.get("payment_terms_template"):
customer_ptt = self.ple.party.isin(
@@ -1011,26 +1040,16 @@ 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")
def add_supplier_filters(self):
supplier = qb.DocType("Supplier")
if self.filters.get("supplier_group"):
groups = get_party_group_with_children("Supplier", self.filters.supplier_group)
self.qb_selection_filter.append(
self.ple.party.isin(
qb.from_(supplier)
.select(supplier.name)
.where(supplier.supplier_group == self.filters.get("supplier_group"))
qb.from_(supplier).select(supplier.name).where(supplier.supplier_group.isin(groups))
)
)
@@ -1082,16 +1101,6 @@ class ReceivablePayableReport:
return ptt
def get_hierarchical_filters(self, doctype, key):
lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"])
doc = qb.DocType(doctype)
ple = self.ple
customer = self.customer
groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt))
customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups))
self.qb_selection_filter.append(ple.party.isin(customers))
def add_accounting_dimensions_filters(self):
accounting_dimensions = get_accounting_dimensions(as_list=False)
@@ -1119,9 +1128,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,
@@ -1251,7 +1257,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(
@@ -1338,19 +1344,23 @@ def get_party_group_with_children(party, party_groups):
if party not in ("Customer", "Supplier"):
return []
group_dtype = f"{party} Group"
if not isinstance(party_groups, list):
party_groups = [d.strip() for d in party_groups.strip().split(",") if d]
return get_nested_set_children(f"{party} Group", party_groups)
all_party_groups = []
for d in party_groups:
if frappe.db.exists(group_dtype, d):
lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"])
children = frappe.get_all(
group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name"
)
all_party_groups += children
def get_nested_set_children(doctype, values):
if not isinstance(values, list):
values = [d.strip() for d in values.split(",") if d.strip()]
if not values:
frappe.throw(_("Please select a valid {0}").format(_(doctype)))
all_values = []
for d in values:
if frappe.db.exists(doctype, d):
lft, rgt = frappe.db.get_value(doctype, d, ["lft", "rgt"])
children = frappe.get_all(doctype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name")
all_values += children
else:
frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d))
frappe.throw(_("{0}: {1} does not exist").format(doctype, d))
return list(set(all_party_groups))
return list(set(all_values))

View File

@@ -6,6 +6,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
from erpnext.tests.utils import ERPNextTestSuite
@@ -778,6 +779,38 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
# Assert that the customer group of each row is in the list of customer groups
self.assertIn(row.customer_group, cus_groups_list)
def test_territory_filter(self):
self.create_sales_invoice()
territory = frappe.db.get_value("Customer", self.customer, "territory")
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"territory": territory,
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
self.assertEqual(
[100.0, 100.0, territory], [report[0].invoiced, report[0].outstanding, report[0].territory]
)
filters.update({"territory": ["_Test Territory United States"]})
self.assertEqual(len(execute(filters)[1]), 0)
filters.update({"territory": [territory, "_Test Territory United States"]})
self.assertEqual(len(execute(filters)[1]), 1)
frappe.db.set_value("Customer", self.customer, "territory", "_Test Territory Maharashtra")
filters.update({"territory": ["_Test Territory India"]})
self.assertEqual(len(execute(filters)[1]), 1)
filters.update({"territory": ["_Test Territory Mars"]})
self.assertRaises(frappe.ValidationError, execute, filters)
filters.update({"territory": " "})
self.assertRaises(frappe.ValidationError, execute, filters)
def test_party_account_filter(self):
si1 = self.create_sales_invoice()
jane = frappe.get_doc(
@@ -1292,3 +1325,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
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

@@ -106,8 +106,11 @@ frappe.query_reports["Accounts Receivable Summary"] = {
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "Link",
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "sales_partner",

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

@@ -191,3 +191,42 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
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

@@ -5,6 +5,8 @@ import frappe
from frappe import _
from frappe.query_builder.functions import IfNull
from erpnext.accounts.report.utils import validate_mandatory_date_range
class TaxWithholdingDetailsReport:
party_types = ("Customer", "Supplier")
@@ -25,11 +27,7 @@ class TaxWithholdingDetailsReport:
return self.get_columns(), self.get_data()
def validate_filters(self):
if not self.filters.from_date or not self.filters.to_date:
frappe.throw(_("From Date and To Date are required"))
if self.filters.from_date > self.filters.to_date:
frappe.throw(_("From Date must be before To Date"))
validate_mandatory_date_range(self.filters)
def get_data(self):
self.entries = self.get_entries_query().run(as_dict=True)

View File

@@ -21,8 +21,7 @@ class TDSComputationSummaryReport(TaxWithholdingDetailsReport):
AGGREGATE_FIELDS = ("total_amount", "tax_amount")
def validate_filters(self):
if self.filters.from_date > self.filters.to_date:
frappe.throw(_("From Date must be before To Date"))
super().validate_filters()
from_year = get_fiscal_year(self.filters.from_date)[0]
to_year = get_fiscal_year(self.filters.to_date)[0]

View File

@@ -1,4 +1,5 @@
import frappe
from frappe import _
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.utils import flt, formatdate, get_datetime_str, get_table_name
@@ -16,6 +17,19 @@ from erpnext.setup.utils import get_exchange_rate
__exchange_rates = {}
def validate_mandatory_date_range(filters, from_field="from_date", to_field="to_date"):
from_date = filters.get(from_field)
to_date = filters.get(to_field)
if not from_date or not to_date:
frappe.throw(
_("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date")))
)
if from_date > to_date:
frappe.throw(_("From Date must be before To Date"))
def get_currency(filters):
"""
Returns a dictionary containing currency information. The keys of the dict are

View File

@@ -1332,7 +1332,7 @@ def has_active_capitalization(asset):
@frappe.whitelist()
def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
def get_values_from_purchase_doc(purchase_doc_name: str, item_code: str, doctype: str):
purchase_doc = frappe.get_doc(doctype, purchase_doc_name)
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]
@@ -1344,7 +1344,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"),
"net_purchase_amount": flt(first_item.base_net_amount),
"net_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

@@ -668,11 +668,13 @@ def get_target_asset_details(asset: str | None = None, company: str | None = Non
@frappe.whitelist()
@erpnext.normalize_ctx_input(ItemDetailsCtx)
def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
frappe.has_permission("Stock Ledger Entry", throw=True)
out = frappe._dict()
item = frappe._dict()
if ctx.item_code:
item = frappe.get_cached_doc("Item", ctx.item_code)
item.check_permission()
out.item_name = item.item_name
out.batch_no = None
@@ -682,6 +684,8 @@ def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
out.stock_uom = item.stock_uom
out.warehouse = get_item_warehouse_(ctx, item, 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, ctx.company)
@@ -722,6 +726,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

@@ -162,6 +162,21 @@ class TestPurchaseOrder(ERPNextTestSuite):
po2.items[0].qty = 110
self.assertRaises(OverAllowanceError, po2.submit)
# Stock over-delivery role must not bypass over-ordering against Material Request.
with self.change_settings(
"Stock Settings", {"role_allowed_to_over_deliver_receive": "Stock Manager"}
):
test_user = frappe.get_doc("User", "test@example.com")
test_user.add_roles("Stock Manager")
mr3 = make_material_request(qty=100)
po3 = make_purchase_order(mr3.name)
po3.supplier = "_Test Supplier"
po3.items[0].qty = 110
with self.set_user("test@example.com"):
po3.flags.ignore_permissions = True
self.assertRaises(OverAllowanceError, po3.submit)
# cleanup
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
@@ -996,6 +1011,8 @@ class TestPurchaseOrder(ERPNextTestSuite):
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
compare_payment_schedules(self, po, pi)
@ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1})
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 1})
def test_internal_transfer_flow(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
@@ -1007,9 +1024,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
)
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1)
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
prepare_data_for_internal_transfer()
supplier = "_Test Internal Supplier 2"
@@ -1448,6 +1462,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(pi_2.status, "Paid")
self.assertEqual(po.status, "Completed")
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 0})
def test_purchase_order_over_billing_missing_item(self):
item1 = make_item(
"_Test Item for Overbilling",

View File

@@ -3873,6 +3873,7 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
for d in deleted_children:
validate_child_on_delete(d, parent, ordered_item)
d.flags.ignore_permissions = True
d.cancel()
d.delete()

View File

@@ -467,7 +467,7 @@ class BuyingController(SubcontractingController):
self.precision("item_tax_amount", item),
)
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

@@ -332,7 +332,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 = []
@@ -347,7 +349,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

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

View File

@@ -445,11 +445,12 @@ class StatusUpdater(Document):
else (0, {}, None, None)
)
role_allowed_to_over_deliver_receive = frappe.get_single_value(
"Stock Settings", "role_allowed_to_over_deliver_receive"
)
role_allowed_to_over_bill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
role = role_allowed_to_over_deliver_receive if qty_or_amount == "qty" else role_allowed_to_over_bill
role = None
if qty_or_amount == "qty":
if args.get("overflow_type") in ("delivery", "receipt"):
role = frappe.get_single_value("Stock Settings", "role_allowed_to_over_deliver_receive")
else:
role = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
overflow_percent = (
(item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]]

View File

@@ -227,7 +227,12 @@ class calculate_taxes_and_totals:
if self.doc.get("is_consolidated") or self.discount_amount_applied:
return
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)
self.calculate_item_rate(item)

View File

@@ -0,0 +1,90 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesAndPurchaseReturn(ERPNextTestSuite):
@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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

63124
erpnext/locale/ro.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -91,6 +91,32 @@ class TestBlanketOrder(ERPNextTestSuite):
frappe.db.set_single_value("Buying Settings", "blanket_order_allowance", 10)
po.submit()
@ERPNextTestSuite.change_settings("Selling Settings", {"blanket_order_allowance": 0})
@ERPNextTestSuite.change_settings("Buying Settings", {"blanket_order_allowance": 0})
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"over_delivery_receipt_allowance": 10, "role_allowed_to_over_deliver_receive": "Stock Manager"},
)
def test_stock_over_delivery_role_does_not_bypass_blanket_order_allowance(self):
test_user = frappe.get_doc("User", "test@example.com")
test_user.add_roles("Stock Manager")
frappe.clear_cache()
for blanket_order_type, doctype, date_field in (
("Selling", "Sales Order", "delivery_date"),
("Purchasing", "Purchase Order", "schedule_date"),
):
bo = make_blanket_order(blanket_order_type=blanket_order_type, quantity=100)
frappe.flags.args.doctype = doctype
order = make_order(bo.name)
order.currency = get_company_currency(order.company)
setattr(order, date_field, today())
order.items[0].qty = 110
with self.set_user("test@example.com"):
order.flags.ignore_permissions = True
self.assertRaises(frappe.ValidationError, order.submit)
def test_party_item_code(self):
item_doc = make_item("_Test Item 1 for Blanket Order")
item_code = item_doc.name

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

@@ -3823,6 +3823,45 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "allow_partial_reservation": 1},
)
def test_partial_reservation_records_full_voucher_qty(self):
# Regression: a short reservation must keep voucher_qty as the full requirement.
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
make_stock_entry as make_stock_entry_test_record,
)
production_item = "Test Partial Reservation FG"
rm_item = "Test Partial Reservation RM"
source_warehouse = "Stores - _TC"
make_item(production_item, {"is_stock_item": 1})
make_item(rm_item, {"is_stock_item": 1})
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
# Only 6 units on hand while the Work Order needs 10.
make_stock_entry_test_record(item_code=rm_item, target=source_warehouse, qty=6, basic_rate=100)
wo = make_wo_order_test_record(
item=production_item,
qty=10,
reserve_stock=1,
source_warehouse=source_warehouse,
)
sre = frappe.get_all(
"Stock Reservation Entry",
filters={"voucher_no": wo.name, "docstatus": 1},
fields=["voucher_qty", "reserved_qty", "status"],
)
self.assertEqual(len(sre), 1)
self.assertEqual(sre[0].reserved_qty, 6)
self.assertEqual(sre[0].voucher_qty, 10)
self.assertEqual(sre[0].status, "Partially Reserved")
def test_auto_stock_reservation_for_batched_raw_material(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
make_stock_entry as make_stock_entry_test_record,

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

@@ -494,3 +494,5 @@ erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
erpnext.patches.v16_0.fix_subcontracting_titles
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root

View File

@@ -0,0 +1,25 @@
import frappe
from frappe.query_builder.functions import Coalesce
def execute():
"""Backfill the statuses of documents reposted before those fields existed.
Without it they show up as drafts and are offered a `Start Reposting` button that would
repost vouchers which are already reposted.
"""
ral = frappe.qb.DocType("Repost Accounting Ledger")
items = frappe.qb.DocType("Repost Accounting Ledger Items")
reposted = (
frappe.qb.from_(ral).select(ral.name).where((ral.docstatus == 1) & (Coalesce(ral.status, "") == ""))
)
frappe.qb.update(items).set(items.status, "Reposted").where(items.parent.isin(reposted)).run()
for docstatus, status in ((1, "Completed"), (2, "Cancelled")):
(
frappe.qb.update(ral)
.set(ral.status, status)
.where((ral.docstatus == docstatus) & (Coalesce(ral.status, "") == ""))
.run()
)

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

@@ -299,6 +299,23 @@ class TestProject(ERPNextTestSuite):
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

@@ -14,6 +14,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

@@ -138,11 +138,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

@@ -219,7 +219,7 @@ def append_row_as_charges(items, tax, reference_row, summary_data):
# Preflight for successful e-invoice export.
def sales_invoice_validate(doc):
# Validate company
if doc.doctype != "Sales Invoice":
if doc.doctype != "Sales Invoice" or doc.is_opening == "Yes":
return
if not doc.company_address:
@@ -303,7 +303,7 @@ def sales_invoice_validate(doc):
# Ensure payment details are valid for e-invoice.
def sales_invoice_on_submit(doc, method):
# Validate payment details
if get_company_country(doc.company) not in [
if doc.is_opening == "Yes" or get_company_country(doc.company) not in [
"Italy",
"Italia",
"Italian Republic",
@@ -369,7 +369,7 @@ def generate_single_invoice(docname):
# Delete e-invoice attachment on cancel.
def sales_invoice_on_cancel(doc, method):
if get_company_country(doc.company) not in [
if doc.is_opening == "Yes" or get_company_country(doc.company) not in [
"Italy",
"Italia",
"Italian Republic",

View File

@@ -151,6 +151,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()
@@ -296,7 +299,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

@@ -688,6 +688,51 @@ class TestSalesOrder(ERPNextTestSuite):
frappe.ValidationError, update_child_qty_rate, "Sales Order", trans_item, so.name
)
def test_update_child_removing_item_without_cancel_and_delete_perms(self):
for workflow_name in frappe.get_all(
"Workflow", filters={"document_type": "Sales Order", "is_active": 1}, pluck="name"
):
workflow = frappe.get_doc("Workflow", workflow_name)
workflow.is_active = 0
workflow.save()
role = "_Test Sales Order Item Editor"
if not frappe.db.exists("Role", role):
frappe.get_doc({"doctype": "Role", "role_name": role, "desk_access": 1}).insert()
frappe.permissions.add_permission("Sales Order", role, 0)
for right, value in {
"read": 1,
"write": 1,
"create": 1,
"submit": 1,
"cancel": 0,
"delete": 0,
}.items():
frappe.permissions.update_permission_property("Sales Order", role, 0, right, value)
frappe.clear_cache()
so = make_sales_order(**{"item_list": [{"item_code": "_Test Item", "qty": 5, "rate": 1000}]})
trans_item = json.dumps(
[
{"item_code": "_Test Item", "qty": 5, "rate": 1000, "docname": so.items[0].name},
{"item_code": "_Test Item 2", "qty": 2, "rate": 500},
]
)
update_child_qty_rate("Sales Order", trans_item, so.name)
so.reload()
self.assertEqual(len(so.items), 2)
test_user = create_user("test_so_item_editor@example.com", role, "Accounts User", "Stock User")
trans_item = json.dumps(
[{"item_code": "_Test Item", "qty": 5, "rate": 1000, "docname": so.items[0].name}]
)
with self.set_user(test_user.name):
update_child_qty_rate("Sales Order", trans_item, so.name)
so.reload()
self.assertEqual(len(so.items), 1)
def test_update_child_qty_rate_with_workflow(self):
from frappe.model.workflow import apply_workflow
@@ -2007,6 +2052,41 @@ class TestSalesOrder(ERPNextTestSuite):
sales_order.save()
self.assertEqual(sales_order.taxes[0].tax_amount, 0)
def test_sales_order_with_shipping_rule_without_cost_center(self):
from erpnext import get_default_cost_center
shipping_rule = frappe.get_doc(
{
"doctype": "Shipping Rule",
"label": "Shipping Rule Without Cost Center - Sales Order Test",
"shipping_rule_type": "Selling",
"company": "_Test Company",
"account": "_Test Account Shipping Charges - _TC",
"calculate_based_on": "Fixed",
"shipping_amount": 50,
}
).insert()
sales_order = make_sales_order(do_not_save=True)
sales_order.shipping_rule = shipping_rule.name
company_cost_center = get_default_cost_center(sales_order.company)
shipping_rule.apply(sales_order)
self.assertEqual(len(sales_order.taxes), 1)
self.assertIsNone(sales_order.taxes[0].cost_center)
for cost_center in (None, "", company_cost_center):
sales_order.taxes[0].cost_center = cost_center
shipping_rule.apply(sales_order)
self.assertEqual(len(sales_order.taxes), 1)
self.assertEqual(sales_order.taxes[0].cost_center, cost_center)
sales_order.taxes[0].cost_center = ""
sales_order.save()
sales_order.reload()
shipping_rule.apply(sales_order)
self.assertEqual(len(sales_order.taxes), 1)
self.assertEqual(sales_order.taxes[0].cost_center, "")
def test_sales_order_partial_advance_payment(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
create_payment_entry,

View File

@@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import unittest
from unittest.mock import patch
import frappe
from frappe.utils.nestedset import (
@@ -14,6 +15,8 @@ from frappe.utils.nestedset import (
from erpnext.tests.utils import ERPNextTestSuite
TRANSLATED_ROOT = "Todos os Grupos de Itens"
class TestItemGroup(ERPNextTestSuite):
def setUp(self):
@@ -209,6 +212,54 @@ class TestItemGroup(ERPNextTestSuite):
merge=True,
)
def test_preset_records_use_existing_root(self):
from erpnext.setup.setup_wizard.operations import install_fixtures
with patch.object(install_fixtures, "get_root_of", return_value=TRANSLATED_ROOT):
records = [
r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group"
]
root_record, *child_records = records
self.assertEqual(root_record["item_group_name"], TRANSLATED_ROOT)
self.assertTrue(root_record["__condition"]())
self.assertEqual({r["parent_item_group"] for r in child_records}, {TRANSLATED_ROOT})
with patch.object(install_fixtures, "get_root_of", return_value="All Item Groups"):
root_record = next(
r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group"
)
self.assertFalse(root_record["__condition"]())
def test_patch_merges_seeded_root_into_existing_root(self):
from erpnext.patches.v16_0.merge_seeded_item_group_root import execute
self._nest_root_under(TRANSLATED_ROOT)
self.assertEqual(
frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT
)
execute()
self.assertFalse(frappe.db.exists("Item Group", "All Item Groups"))
self.assertEqual(
frappe.get_all("Item Group", filters={"parent_item_group": ("is", "not set")}, pluck="name"),
[TRANSLATED_ROOT],
)
self.assertEqual(
frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT
)
self.test_basic_tree()
def _nest_root_under(self, new_root):
"""Recreate the tree left behind by seeding a root under a pre-existing one."""
frappe.get_doc({"doctype": "Item Group", "item_group_name": new_root, "is_group": 1}).insert()
ig = frappe.qb.DocType("Item Group")
frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run()
frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run()
rebuild_tree("Item Group")
def _move_it_back(self):
group_b = frappe.get_doc("Item Group", "_Test Item Group B")
group_b.parent_item_group = "All Item Groups"

View File

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

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

@@ -712,6 +712,76 @@ class TestDeliveryNote(ERPNextTestSuite):
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

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

View File

@@ -411,13 +411,187 @@ frappe.ui.form.on("Material Request", {
},
make_purchase_order: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order",
frm: frm,
run_link_triggers: true,
frappe.call({
method: "erpnext.stock.doctype.material_request.material_request.get_item_default_suppliers",
args: {
source_name: frm.doc.name,
filtered_children: (frm.get_selected() || {}).items || [],
},
freeze: true,
callback: function (r) {
const items = r.message || [];
const suppliers = new Set(items.map((item) => item.supplier || ""));
if (suppliers.size > 1) {
frm.events.select_suppliers_for_items(frm, items);
return;
}
frappe.model.open_mapped_doc({
method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order",
frm: frm,
args: { supplier: items.length ? items[0].supplier : null },
run_link_triggers: true,
});
},
});
},
select_suppliers_for_items: function (frm, items) {
const rows = items.map((item) => Object.assign({}, item, { qty: item.pending_qty, __checked: 1 }));
const supplier_query = () => {
return { filters: { disabled: 0, prevent_pos: 0 } };
};
const dialog = new frappe.ui.Dialog({
title: __("Select Supplier for Items"),
size: "large",
fields: [
{
fieldname: "supplier",
fieldtype: "Link",
options: "Supplier",
label: __("Set Supplier for All Items"),
get_query: supplier_query,
onchange: function () {
const supplier = dialog.get_value("supplier");
if (!supplier) return;
rows.forEach((row) => (row.supplier = supplier));
dialog.fields_dict.items.grid.refresh();
},
},
{ fieldtype: "Column Break" },
{ fieldtype: "Section Break" },
{
fieldname: "items",
fieldtype: "Table",
label: __("Items"),
cannot_add_rows: true,
cannot_delete_rows: true,
in_place_edit: true,
data: rows,
get_data: () => rows,
description: __("A separate Purchase Order is created for each Supplier."),
fields: [
{
fieldtype: "Data",
fieldname: "material_request_item",
hidden: 1,
},
{
fieldtype: "Link",
fieldname: "item_code",
options: "Item",
label: __("Item Code"),
read_only: 1,
in_list_view: 1,
columns: 3,
},
{
fieldtype: "Data",
fieldname: "item_name",
label: __("Item Name"),
read_only: 1,
in_list_view: 1,
columns: 2,
},
{
fieldtype: "Float",
fieldname: "pending_qty",
hidden: 1,
},
{
fieldtype: "Float",
fieldname: "qty",
label: __("Quantity"),
reqd: 1,
in_list_view: 1,
columns: 1,
},
{
fieldtype: "Link",
fieldname: "uom",
options: "UOM",
label: __("UOM"),
read_only: 1,
in_list_view: 1,
columns: 1,
},
{
fieldtype: "Link",
fieldname: "supplier",
options: "Supplier",
label: __("Supplier"),
get_query: supplier_query,
reqd: 1,
in_list_view: 1,
columns: 3,
},
],
},
],
primary_action_label: __("Create"),
primary_action: async function (values) {
const item_suppliers = (values.items || []).filter((row) => row.__checked);
if (!item_suppliers.length) {
frappe.throw(__("Select at least one Item"));
}
const item_link = (row) =>
frappe.utils.get_form_link(
"Item",
row.item_code,
true,
frappe.utils.escape_html(row.item_code)
);
const missing_supplier = item_suppliers.find((row) => !row.supplier);
if (missing_supplier) {
frappe.throw(__("Select a Supplier for Item {0}", [item_link(missing_supplier)]));
}
const invalid_qty = item_suppliers.find(
(row) => flt(row.qty) <= 0 || flt(row.qty) > flt(row.pending_qty)
);
if (invalid_qty) {
const pending_qty = `${format_number(invalid_qty.pending_qty)} ${frappe.utils.escape_html(
invalid_qty.uom
)}`;
frappe.throw(
__("Quantity for Item {0} must be greater than zero and cannot exceed {1}", [
item_link(invalid_qty),
`<b>${pending_qty}</b>`,
])
);
}
if (!(await erpnext.utils.confirm_if_drafts_exist(frm.doc, "Purchase Order"))) {
return;
}
frappe.call({
method: "erpnext.stock.doctype.material_request.material_request.make_purchase_orders_by_supplier",
args: { source_name: frm.doc.name, item_suppliers: item_suppliers },
freeze: true,
callback: function (r) {
if (r.exc) return;
dialog.hide();
const purchase_orders = r.message || [];
if (purchase_orders.length === 1) {
frappe.set_route("Form", "Purchase Order", purchase_orders[0]);
}
},
});
},
});
dialog.show();
},
make_request_for_quotation: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.stock.doctype.material_request.material_request.make_request_for_quotation",

View File

@@ -13,12 +13,24 @@ from frappe import _, msgprint
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Order
from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_sep, nowdate
from frappe.utils import (
cint,
comma_and,
cstr,
flt,
get_link_to_form,
getdate,
new_line_sep,
nowdate,
)
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.stock.get_item_details import get_price_list_rate_for
from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.get_item_details import get_default_supplier, get_price_list_rate_for
from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty
from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
get_subcontracting_boms_for_finished_goods,
@@ -485,6 +497,16 @@ def set_missing_values(source, target_doc):
target_doc.run_method("calculate_taxes_and_totals")
def get_source_item_for_qty(item, qty):
"""Copy of the source row whose pending quantity is the requested quantity."""
source_item = frappe._dict(item.as_dict())
source_item.ordered_qty = 0
source_item.received_qty = 0
source_item.stock_qty = flt(qty) * flt(item.conversion_factor)
return source_item
def update_item(obj, target, source_parent):
target.conversion_factor = obj.conversion_factor
@@ -538,7 +560,7 @@ def update_status(name, status):
@frappe.whitelist()
def make_purchase_order(source_name, target_doc=None, args=None):
if args is None:
args = {}
args = frappe.flags.args or {}
if isinstance(args, str):
args = json.loads(args)
@@ -546,10 +568,19 @@ def make_purchase_order(source_name, target_doc=None, args=None):
frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting"
)
requested_qty = args.get("requested_qty") or {}
def postprocess(source, target_doc):
target_doc.is_subcontracted = is_subcontracted
if args.get("supplier"):
target_doc.supplier = args.get("supplier")
set_missing_values(source, target_doc)
def update_requested_item(obj, target, source_parent):
if obj.name in requested_qty:
obj = get_source_item_for_qty(obj, requested_qty[obj.name])
update_item(obj, target, source_parent)
def select_item(d):
filtered_items = args.get("filtered_children", [])
child_filter = d.name in filtered_items if filtered_items else True
@@ -589,7 +620,7 @@ def make_purchase_order(source_name, target_doc=None, args=None):
"doctype": "Purchase Order Item",
"field_map": generate_field_map(),
"field_no_map": ["item_code", "item_name", "qty"] if is_subcontracted else [],
"postprocess": update_item,
"postprocess": update_requested_item,
"condition": select_item,
},
},
@@ -601,6 +632,119 @@ def make_purchase_order(source_name, target_doc=None, args=None):
return doclist
def get_default_supplier_for_item(item_code: str, company: str) -> str | None:
return get_default_supplier(
frappe._dict(),
get_item_defaults(item_code, company),
get_item_group_defaults(item_code, company),
get_brand_defaults(item_code, company),
)
@frappe.whitelist()
def get_item_default_suppliers(source_name: str, filtered_children: str | list | None = None) -> list[dict]:
"""Pending items of the Material Request with their default supplier."""
filtered_children = frappe.parse_json(filtered_children) if filtered_children else []
material_request = frappe.get_doc("Material Request", source_name)
material_request.check_permission("read")
items = []
for item in material_request.items:
if filtered_children and item.name not in filtered_children:
continue
ordered_qty = flt(item.ordered_qty) or flt(item.received_qty)
if ordered_qty >= flt(item.stock_qty):
continue
items.append(
{
"material_request_item": item.name,
"item_code": item.item_code,
"item_name": item.item_name,
"pending_qty": (flt(item.stock_qty) - ordered_qty) / (flt(item.conversion_factor) or 1),
"uom": item.uom,
"supplier": get_default_supplier_for_item(item.item_code, material_request.company),
}
)
return items
@frappe.whitelist(methods=["POST"])
def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | list) -> list[str]:
"""Create one draft Purchase Order per supplier for the given Material Request items."""
item_suppliers = frappe.parse_json(item_suppliers)
if not item_suppliers:
frappe.throw(_("Select at least one Item"))
pending_items = {
d["material_request_item"]: frappe._dict(d) for d in get_item_default_suppliers(source_name)
}
items_by_supplier = {}
requested_items = set()
for row in item_suppliers:
row = frappe._dict(row)
pending = pending_items.get(row.material_request_item) or frappe._dict()
item_link = get_link_to_form("Item", row.item_code)
if row.material_request_item in requested_items:
frappe.throw(_("Item {0} cannot be ordered more than once").format(item_link))
requested_items.add(row.material_request_item)
if not row.supplier:
frappe.throw(_("Select a Supplier for Item {0}").format(item_link))
if flt(row.qty) <= 0 or flt(row.qty) > flt(pending.pending_qty):
pending_qty = frappe.format_value(flt(pending.pending_qty), "Float")
frappe.throw(
_("Quantity for Item {0} must be greater than zero and cannot exceed {1}").format(
item_link, frappe.bold(f"{pending_qty} {pending.uom or ''}".strip())
)
)
items_by_supplier.setdefault(row.supplier, {})[row.material_request_item] = flt(row.qty)
purchase_orders = []
is_rescheduled = False
for supplier, requested_qty in items_by_supplier.items():
purchase_order = make_purchase_order(
source_name,
args={
"supplier": supplier,
"filtered_children": list(requested_qty),
"requested_qty": requested_qty,
},
)
for item in purchase_order.items:
if not item.schedule_date:
item.schedule_date = nowdate()
is_rescheduled = True
purchase_order.insert()
purchase_orders.append(purchase_order.name)
if is_rescheduled:
frappe.toast(
_("{0} was set to today for items whose requested date has passed").format(
_(frappe.get_meta("Purchase Order Item").get_label("schedule_date"))
),
indicator="orange",
)
if len(purchase_orders) > 1:
frappe.msgprint(
_("{0} created").format(
comma_and([get_link_to_form("Purchase Order", name) for name in purchase_orders])
)
)
return purchase_orders
@frappe.whitelist()
def make_request_for_quotation(source_name, target_doc=None):
doclist = get_mapped_doc(

View File

@@ -6,7 +6,7 @@
import frappe
from frappe.utils import flt, today
from frappe.utils import add_days, flt, getdate, today
from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.stock.doctype.item.test_item import create_item
@@ -847,6 +847,28 @@ class TestMaterialRequest(ERPNextTestSuite):
mr = frappe.get_doc("Material Request", mr.name)
self.assertEqual(mr.per_ordered, 100)
def test_fractional_conversion_factor_for_purchase(self):
item = create_item("_Test Fractional Conversion Item", stock_uom="Kg", is_purchase_item=1)
conversion_factor = 0.453592292
mr = make_material_request(
item_code=item.name,
qty=1000,
uom="Pound",
conversion_factor=conversion_factor,
)
mr.reload()
self.assertEqual(mr.items[0].conversion_factor, conversion_factor)
po = make_purchase_order(mr.name)
po.supplier = "_Test Supplier"
po.insert()
po.reload()
self.assertEqual(po.items[0].conversion_factor, conversion_factor)
self.assertEqual(po.items[0].stock_qty, mr.items[0].stock_qty)
def test_customer_provided_parts_mr(self):
create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0)
existing_requested_qty = self._get_requested_qty("_Test Customer", "_Test Warehouse - _TC")
@@ -1189,6 +1211,144 @@ class TestMaterialRequest(ERPNextTestSuite):
self.assertEqual(material_request.status, "Transferred")
self.assertEqual(material_request.transfer_status, "Completed")
def test_get_item_default_suppliers(self):
from erpnext.stock.doctype.material_request.material_request import get_item_default_suppliers
with_supplier = create_item_with_default_supplier("_Test MR Item Supplier A", "_Test Supplier")
without_supplier = create_item("_Test MR Item Without Supplier").name
mr = make_material_request_for_items([with_supplier, without_supplier])
items = get_item_default_suppliers(mr.name)
self.assertEqual([d["item_code"] for d in items], [with_supplier, without_supplier])
self.assertEqual(items[0]["supplier"], "_Test Supplier")
self.assertFalse(items[1]["supplier"])
self.assertEqual(items[0]["pending_qty"], 10)
def test_make_purchase_order_sets_supplier(self):
mr = make_material_request_for_items(["_Test Item"])
po = make_purchase_order(mr.name, args={"supplier": "_Test Supplier"})
self.assertEqual(po.supplier, "_Test Supplier")
def test_make_purchase_orders_by_supplier(self):
from erpnext.stock.doctype.material_request.material_request import make_purchase_orders_by_supplier
item_codes = [create_item(f"_Test MR Grouped Item {index}").name for index in range(1, 4)]
mr = make_material_request_for_items(item_codes)
suppliers = ["_Test Supplier", "_Test Supplier", "_Test Supplier 1"]
purchase_orders = make_purchase_orders_by_supplier(
mr.name,
[
{
"material_request_item": item.name,
"item_code": item.item_code,
"qty": qty,
"supplier": supplier,
}
for item, supplier, qty in zip(mr.items, suppliers, [10, 10, 4], strict=True)
],
)
self.assertEqual(len(purchase_orders), 2)
first, second = (frappe.get_doc("Purchase Order", name) for name in purchase_orders)
self.assertEqual(first.supplier, "_Test Supplier")
self.assertEqual([d.item_code for d in first.items], item_codes[:2])
self.assertEqual(second.supplier, "_Test Supplier 1")
self.assertEqual([d.item_code for d in second.items], item_codes[2:])
self.assertEqual(second.items[0].qty, 4)
self.assertEqual(second.items[0].stock_qty, 4)
def test_make_purchase_orders_by_supplier_sets_schedule_date(self):
from erpnext.stock.doctype.material_request.material_request import make_purchase_orders_by_supplier
mr = make_material_request_for_items(["_Test Item"])
frappe.db.set_value("Material Request Item", mr.items[0].name, "schedule_date", add_days(today(), -1))
purchase_orders = make_purchase_orders_by_supplier(
mr.name,
[
{
"material_request_item": mr.items[0].name,
"item_code": "_Test Item",
"qty": 10,
"supplier": "_Test Supplier",
}
],
)
po = frappe.get_doc("Purchase Order", purchase_orders[0])
self.assertEqual(po.schedule_date, getdate(today()))
alerts = [m for m in frappe.get_message_log() if m.get("alert")]
self.assertTrue(any("was set to today" in m.get("message") for m in alerts))
def test_make_purchase_orders_by_supplier_invalid_rows(self):
from erpnext.stock.doctype.material_request.material_request import make_purchase_orders_by_supplier
mr = make_material_request_for_items(["_Test Item"])
row = {
"material_request_item": mr.items[0].name,
"item_code": "_Test Item",
"qty": 10,
"supplier": "_Test Supplier",
}
for invalid in [{"supplier": None}, {"qty": 0}, {"qty": -5}, {"qty": 11}]:
self.assertRaises(
frappe.ValidationError, make_purchase_orders_by_supplier, mr.name, [row | invalid]
)
self.assertRaises(frappe.ValidationError, make_purchase_orders_by_supplier, mr.name, [])
self.assertRaises(
frappe.ValidationError,
make_purchase_orders_by_supplier,
mr.name,
[row, row | {"supplier": "_Test Supplier 1"}],
)
def create_item_with_default_supplier(item_code, supplier):
item = create_item(item_code)
item.set("item_defaults", [])
item.append(
"item_defaults",
{
"company": "_Test Company",
"default_warehouse": "_Test Warehouse - _TC",
"default_supplier": supplier,
},
)
item.save()
return item.name
def make_material_request_for_items(item_codes, **args):
args = frappe._dict(args)
mr = frappe.new_doc("Material Request")
mr.material_request_type = args.material_request_type or "Purchase"
mr.company = args.company or "_Test Company"
mr.schedule_date = today()
for item_code in item_codes:
mr.append(
"items",
{
"item_code": item_code,
"qty": args.qty or 10,
"schedule_date": today(),
"warehouse": args.warehouse or "_Test Warehouse - _TC",
},
)
mr.insert()
mr.submit()
return mr
def get_in_transit_warehouse(company):
if not frappe.db.exists("Warehouse Type", "Transit"):

View File

@@ -433,29 +433,10 @@ class PurchaseReceipt(BuyingController):
row.received_qty,
)
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_for_on_hold_or_closed_status("Purchase Order", "purchase_order")
# 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

@@ -3134,11 +3134,14 @@ class TestPurchaseReceipt(ERPNextTestSuite):
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company")
frappe.local.enable_perpetual_inventory["_Test Company"] = 1
old_inventory_account = frappe.db.get_value("Company", "_Test Company", "default_inventory_account")
frappe.db.set_value(
"Company",
"_Test Company",
"stock_received_but_not_billed",
"Stock Received But Not Billed - _TC",
{
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
"default_inventory_account": "Stock In Hand - _TC",
},
)
pr = make_purchase_receipt(qty=10, rate=1000, do_not_submit=1)
@@ -3174,6 +3177,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
)
self.assertCountEqual(expected_gle, gl_entries)
frappe.local.enable_perpetual_inventory["_Test Company"] = old_perpetual_inventory
frappe.db.set_value("Company", "_Test Company", "default_inventory_account", old_inventory_account)
def test_purchase_receipt_with_use_serial_batch_field_for_rejected_qty(self):
batch_item = make_item(
@@ -6180,6 +6184,32 @@ class TestPurchaseReceipt(ERPNextTestSuite):
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 create_asset_category_for_pr_test():
category_name = "Test Asset Category for PR"

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