Compare commits

...

31 Commits

Author SHA1 Message Date
rohitwaghchaure
431dc2e5f1 fix: incorrect batch-wise valuation rate for entries with same posting datetime (#57794)
* fix: incorrect batch-wise valuation rate for entries with same posting datetime

The tie-breaker in get_batch_no_ledgers compared the bundle's creation
against the SLE's creation. These are different timelines - a bundle can
be created (drafted) much before its SLE (created at submission). For
entries sharing a posting datetime (backdated / amended vouchers), this
mis-ordered the entries against the ledger's replay order (SLE creation),
causing double counting or omission of batch qty / value and runaway
outgoing rates that no repost could heal.

Now the tie is broken using the creation of the bundle's own SLE (same
timeline on both sides). When the valuation runs through the bundle
before its SLE exists, the entry is by definition last in its timestamp
group, so all same-timestamp entries already in the ledger precede it.

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

* test: batch-wise valuation ordering for same posting datetime entries

Covers both tie-breaking branches of get_batch_no_ledgers:
- submission (pre-insertion) branch: same-timestamp inward at a different
  rate plus a multi-row outward voucher (same item and warehouse), at
  submission and after a backdated repost
- existing-SLE branch: a bundle created after its sibling's SLE, the
  ordering must follow the SLE creation and not the bundle creation

Both tests fail with the previous parent.creation < sle.creation
tie-breaker and pass with the fix.

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

---------

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

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

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

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

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

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

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

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

(cherry picked from commit 732c884633)

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

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

(cherry picked from commit a3e9d13da3)

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

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

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

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

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

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

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

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

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

(cherry picked from commit 269cc6ee3b)

# Conflicts:
#	erpnext/controllers/taxes_and_totals.py
2026-08-02 06:25:36 +00:00
20 changed files with 518 additions and 46 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import json
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import flt, nowtime, today
from frappe.utils import add_days, add_to_date, flt, nowtime, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
@@ -1512,3 +1512,186 @@ class TestSerialandBatchBundleLogic(FrappeTestCase):
self.assertNotIn(bundles[1], bundle_wise_serial_nos)
self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no])
@change_settings("Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1})
def test_batchwise_valuation_for_same_posting_datetime_entries(self):
# an inward at a different rate and multiple outward rows with the same
# item and warehouse share the same posting datetime, the tie-breaking
# must include the same-timestamp entries which are already part of the
# ledger and must not let the outward rows count each other
item_code = make_item(
"Test Batchwise Same Posting Datetime Item 1",
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBSPD-ITEM1-.#####",
"valuation_method": "FIFO",
},
).name
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(
item_code=item_code,
qty=10,
rate=100,
target=warehouse,
posting_date=add_days(today(), -5),
posting_time="12:00:00",
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
self.assertTrue(frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"))
# same posting datetime as the outward rows below, at a different rate
make_stock_entry(
item_code=item_code,
qty=20,
rate=250,
target=warehouse,
batch_no=batch_no,
use_serial_batch_fields=1,
posting_date=add_days(today(), -3),
posting_time="12:00:00",
)
issue = make_stock_entry(
item_code=item_code,
qty=2,
source=warehouse,
posting_date=add_days(today(), -3),
posting_time="12:00:00",
do_not_save=True,
)
for qty in [3, 4]:
issue.append(
"items",
{
"item_code": item_code,
"s_warehouse": warehouse,
"qty": qty,
"conversion_factor": 1,
},
)
issue.save()
issue.submit()
# (10 * 100 + 20 * 250) / 30 = 200
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=200.0, balance_value=4200.0)
# backdated receipt reposts the same posting datetime cluster
make_stock_entry(
item_code=item_code,
qty=10,
rate=100,
target=warehouse,
batch_no=batch_no,
use_serial_batch_fields=1,
posting_date=add_days(today(), -4),
posting_time="12:00:00",
)
# (20 * 100 + 20 * 250) / 40 = 175
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=175.0, balance_value=5425.0)
@change_settings("Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1})
def test_batchwise_valuation_when_bundle_created_before_the_sle(self):
# a bundle can be created (drafted) much before / after its SLE, the
# tie-breaking for the same posting datetime entries must follow the
# SLE creation and not the bundle creation
item_code = make_item(
"Test Batchwise Same Posting Datetime Item 2",
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBSPD-ITEM2-.#####",
"valuation_method": "FIFO",
},
).name
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(
item_code=item_code,
qty=10,
rate=100,
target=warehouse,
posting_date=add_days(today(), -5),
posting_time="12:00:00",
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
# inward at a different rate, same posting datetime as the outward below
inward = make_stock_entry(
item_code=item_code,
qty=10,
rate=200,
target=warehouse,
batch_no=batch_no,
use_serial_batch_fields=1,
posting_date=add_days(today(), -3),
posting_time="12:00:00",
)
outward = make_stock_entry(
item_code=item_code,
qty=10,
source=warehouse,
posting_date=add_days(today(), -3),
posting_time="12:00:00",
)
# simulate the inward's bundle drafted after the outward's SLE, the
# bundle creation timeline no longer matches the SLE creation timeline
outward_sle_creation = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": outward.name, "is_cancelled": 0},
"creation",
)
frappe.db.set_value(
"Serial and Batch Bundle",
inward.items[0].serial_and_batch_bundle,
"creation",
add_to_date(outward_sle_creation, minutes=30),
update_modified=False,
)
repost = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Item and Warehouse",
"item_code": item_code,
"warehouse": warehouse,
"posting_date": add_days(today(), -6),
"posting_time": "00:00:00",
"allow_negative_stock": 1,
}
)
repost.submit()
# (10 * 100 + 10 * 200) / 20 = 150, the inward precedes the outward as
# per the SLE creation even though its bundle was created afterwards
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=150.0, balance_value=1500.0)
def assert_batchwise_outgoing_rate(self, item_code, outgoing_rate, balance_value):
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"item_code": item_code, "is_cancelled": 0},
fields=["actual_qty", "stock_value_difference", "stock_value"],
order_by="posting_datetime, creation",
)
for sle in sl_entries:
if sle.actual_qty > 0:
continue
self.assertEqual(flt(sle.stock_value_difference, 2), flt(sle.actual_qty * outgoing_rate, 2))
self.assertEqual(flt(sl_entries[-1].stock_value, 2), flt(balance_value, 2))

View File

@@ -829,14 +829,43 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
parent = frappe.qb.DocType("Serial and Batch Bundle")
child = frappe.qb.DocType("Serial and Batch Entry")
sle_creation = self.sle.creation if self.sle.get("name") else None
if not self.sle.get("name") and self.sle.get("serial_and_batch_bundle"):
sle_creation = frappe.db.get_value(
"Stock Ledger Entry",
{"serial_and_batch_bundle": self.sle.serial_and_batch_bundle, "is_cancelled": 0},
"creation",
)
timestamp_condition = ""
if self.sle.posting_datetime:
timestamp_condition = parent.posting_datetime < self.sle.posting_datetime
if self.sle.creation:
timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & (
parent.creation < self.sle.creation
sle_table = frappe.qb.DocType("Stock Ledger Entry")
if sle_creation:
# bundle creation and SLE creation are different timelines (a
# bundle can be created much before its SLE), so break the tie
# using the creation of the bundle's own SLE
tie_condition = ExistsCriterion(
frappe.qb.from_(sle_table)
.select(sle_table.name)
.where(
(sle_table.serial_and_batch_bundle == parent.name)
& (sle_table.is_cancelled == 0)
& (sle_table.creation < sle_creation)
)
)
else:
# the current entry is not yet in the ledger and will get the
# latest creation, so the same-timestamp entries which are
# already in the ledger precede it
tie_condition = ExistsCriterion(
frappe.qb.from_(sle_table)
.select(sle_table.name)
.where((sle_table.serial_and_batch_bundle == parent.name) & (sle_table.is_cancelled == 0))
)
timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & tie_condition
query = (
frappe.qb.from_(parent)