Compare commits

..

5 Commits

Author SHA1 Message Date
Mihir Kandoi
837ea0d0ee fix: resolve version-15 backport conflicts
Keep validate_warehouses() and add validate_over_delivery_receipt_allowance();
validate_serial_and_batch_no_settings() does not exist on this branch.

Keep this branch's field labels and add only the non_negative flag the
backported commit introduced.

Drop test_over_order_allowance_against_material_request and
test_blanket_order_over_order_aggregated_across_rows: both are develop-only
context the cherry-pick swallowed into the conflicts, and Buying Settings has
no over_order_allowance field here.

Use frappe.tests.utils.change_settings instead of ERPNextTestSuite, which this
branch does not have.

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:58:57 +05:30
Afsal Syed
d95e6f92c9 test(stock): prevent settings leakage in purchase order tests
(cherry picked from commit 99630f40eb)

# Conflicts:
#	erpnext/buying/doctype/purchase_order/test_purchase_order.py
2026-08-03 07:09:56 +00:00
Afsal Syed
8de4b3a85e test(stock): add test cases verifying stock over delivery role does not bypass order allowance
(cherry picked from commit 0b271e24b6)

# Conflicts:
#	erpnext/buying/doctype/purchase_order/test_purchase_order.py
#	erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py
2026-08-03 07:09:56 +00:00
Afsal Syed
50df324519 fix(stock): scope over deliver/receive role check to delivery and receipt overflow
(cherry picked from commit 248873034d)

# Conflicts:
#	erpnext/controllers/status_updater.py
2026-08-03 07:09:55 +00:00
Afsal Syed
850242582d 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:09:55 +00:00
19 changed files with 89 additions and 445 deletions

View File

@@ -473,24 +473,19 @@ def get_child_docs(doc: list) -> list:
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
docs_with_deferred_revenue = ()
docs_with_deferred_expense = ()
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 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,
)
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

@@ -849,12 +849,10 @@ def validate_account_party_type(self):
def get_dashboard_info(party_type, party, loyalty_program=None):
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)
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
)

View File

@@ -948,6 +948,8 @@ class TestPurchaseOrder(FrappeTestCase):
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
compare_payment_schedules(self, po, pi)
@change_settings("Selling Settings", {"maintain_same_sales_rate": 1})
@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 (
@@ -959,9 +961,6 @@ class TestPurchaseOrder(FrappeTestCase):
)
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"

View File

@@ -158,28 +158,10 @@ def validate_returned_items(doc):
):
frappe.throw(_("Warehouse is mandatory"))
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
items_returned = True
elif d.item_name:
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
items_returned = True
if not items_returned:
frappe.throw(_("Atleast one item should be entered with negative quantity in return document"))

View File

@@ -312,13 +312,12 @@ class StatusUpdater(Document):
qty_or_amount,
)
role_allowed_to_over_deliver_receive = frappe.db.get_single_value(
"Stock Settings", "role_allowed_to_over_deliver_receive"
)
role_allowed_to_over_bill = frappe.db.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

@@ -1,89 +0,0 @@
# 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,7 +132,6 @@ 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:
@@ -143,15 +142,6 @@ 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

@@ -1,7 +1,7 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_months, today
from erpnext import get_company_currency
@@ -91,6 +91,32 @@ class TestBlanketOrder(FrappeTestCase):
frappe.db.set_single_value("Buying Settings", "blanket_order_allowance", 10)
po.submit()
@change_settings("Selling Settings", {"blanket_order_allowance": 0})
@change_settings("Buying Settings", {"blanket_order_allowance": 0})
@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

@@ -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="{{frappe.utils.escape_html(row.image)}}">
<img style="width:50px;height:50px;" src="{{row.image}}">
{% } else { %}
<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 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>
<div class="col-sm-3">
@@ -13,7 +13,7 @@
{% } else { %}
{{row.item_link}}
<p>
{{frappe.utils.escape_html(row.item_name)}}
{{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="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Add") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ escape(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="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Move") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ escape(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 = frappe.utils.escape_html(d.off_status_image)
d.status_image = d.off_status_image
return data

View File

@@ -1,19 +1,17 @@
<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_title }}">
{{ item_title }}</a>
<a class="grey list-id" data-name="{{item.name}}"
title="{{ item.item_name || item.name}}">
{{item.item_name || item.name}}</a>
</div>
</div>
<div class="image-view-body">
<a data-item-code="{{ item_name }}"
title="{{ item_title }}"
<a data-item-code="{{ item.name }}"
title="{{ item.item_name || item.name }}"
>
<div class="image-field"
style="
@@ -24,11 +22,11 @@
>
{% if (!item.image) { %}
<span class="placeholder-text">
{%= frappe.get_abbr(item_title) %}
{%= frappe.get_abbr(item.item_name || item.name) %}
</span>
{% } %}
{% if (item.image) { %}
<img src="{{ frappe.utils.escape_html(item.image) }}" alt="{{ item_title }}">
<img src="{{ item.image }}" alt="{{item.item_name || item.name}}">
{% } %}
</div>
</a>

View File

@@ -1,5 +1,4 @@
{% $.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") { %}
@@ -18,14 +17,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_workstation_name, 2)}}</div>
<div class="workstation-image-cls workstation-abbr">{{frappe.get_abbr(row.name, 2)}}</div>
{% } %}
</a>
</div>
</div>
<div class="workstation-card" style="display: grid;">
<span class="ellipsis" title="{{row_workstation_name}}">
{{row_workstation_name}}
<span class="ellipsis" title="{{row.name}}">
{{row.workstation_name}}
</span>
</div>
</div>

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, ignore_permissions=True)
remove_user_permission("Employee", self.name, existing_user_id)
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, ignore_permissions=True)
remove_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
remove_user_permission("Employee", self.name, self.user_id)
remove_user_permission("Company", self.company, self.user_id)
elif not employee_user_permission_exists and self.create_user_permission:
add_user_permission("Employee", self.name, self.user_id, ignore_permissions=True)
add_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
add_user_permission("Employee", self.name, self.user_id)
add_user_permission("Company", self.company, self.user_id)
def update_user(self):
# add employee role if missing

View File

@@ -837,17 +837,7 @@ 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,45 +360,6 @@ 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

@@ -5,7 +5,7 @@ import json
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, add_to_date, flt, nowtime, today
from frappe.utils import 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,186 +1512,3 @@ 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

@@ -131,7 +131,8 @@
"description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.",
"fieldname": "over_delivery_receipt_allowance",
"fieldtype": "Float",
"label": "Over Delivery/Receipt Allowance (%)"
"label": "Over Delivery/Receipt Allowance (%)",
"non_negative": 1
},
{
"default": "Stop",
@@ -282,7 +283,8 @@
"description": "The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units.",
"fieldname": "mr_qty_allowance",
"fieldtype": "Float",
"label": "Over Transfer Allowance"
"label": "Over Transfer Allowance",
"non_negative": 1
},
{
"default": "0",
@@ -446,7 +448,8 @@
"description": "The percentage you are allowed to pick more items in the pick list than the ordered quantity.",
"fieldname": "over_picking_allowance",
"fieldtype": "Percent",
"label": "Over Picking Allowance"
"label": "Over Picking Allowance",
"non_negative": 1
},
{
"default": "1",
@@ -528,7 +531,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-27 22:39:16.812184",
"modified": "2026-08-01 23:35:02.896836",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -101,6 +101,7 @@ class StockSettings(Document):
)
self.validate_warehouses()
self.validate_over_delivery_receipt_allowance()
self.cant_change_valuation_method()
self.validate_clean_description_html()
self.validate_pending_reposts()
@@ -110,6 +111,10 @@ class StockSettings(Document):
self.change_precision_for_purchase()
self.validate_do_not_use_batchwise_valuation()
def validate_over_delivery_receipt_allowance(self):
if not self.over_delivery_receipt_allowance:
self.role_allowed_to_over_deliver_receive = None
def validate_do_not_use_batchwise_valuation(self):
doc_before_save = self.get_doc_before_save()
if not doc_before_save:

View File

@@ -829,43 +829,14 @@ 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
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)
)
if self.sle.creation:
timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & (
parent.creation < self.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)