Compare commits

...

8 Commits

Author SHA1 Message Date
mergify[bot]
75b2130038 fix: set correct transaction type on args copy while evaulating item wise pricing rule (backport #59406) (#59408)
fix: set correct transaction type on args copy while evaulating item wise pricing rule (#59406)

(cherry picked from commit 9cc2c2272a)

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-25 06:33:25 +05:30
Shllokkk
06d60f66b1 Merge pull request #59388 from frappe/mergify/bp/version-15-hotfix/pr-59386
fix: skip activities fetch on unsaved CRM doc (backport #59386)
2026-09-24 18:18:14 +05:30
Shllokkk
a344958a73 fix: skip activities fetch on unsaved CRM doc
(cherry picked from commit 87052020fb)
2026-09-24 12:41:38 +00:00
Sudharsanan Ashok
c39fdadd1f Merge pull request #59384 from frappe/mergify/bp/version-15-hotfix/pr-59381
fix(stock): restrict delivery to reserved serial nos when ensure delivery by serial no is set (backport #59381)
2026-09-24 17:07:02 +05:30
Sudharsanan Ashok
9703007188 fix(stock): restrict delivery to reserved serial nos when ensure delivery by serial no is set (#59381)
delivery note and sales invoice with update stock now throw on submit if a sales order item with ensure delivery based on produced serial no has no serial reservation, or if a delivered serial no is not one of the serial nos reserved for it. the check runs after the stock ledger is posted, so serial nos auto picked on submit are validated too. the checkbox is hidden and cleared on the sales order when stock reservation is disabled in stock settings, and the check is skipped in that case.

(cherry picked from commit 5306aaf53d)
2026-09-24 16:19:30 +05:30
Sudharsanan Ashok
cde7601804 Merge pull request #59365 from frappe/mergify/bp/version-15-hotfix/pr-59168
fix(regional): report uae vat 201 sales vat in company currency  (backport #59168)
2026-09-24 10:58:23 +05:30
Sudharsanan11
6544048b51 fix: resolve conflicts 2026-09-24 10:34:58 +05:30
Sudharsanan Ashok
134f20d6a1 fix(regional): report uae vat 201 sales vat in company currency (#59168)
(cherry picked from commit 6e0ec5af31)

# Conflicts:
#	erpnext/regional/report/uae_vat_201/uae_vat_201.py
2026-09-24 04:39:15 +00:00
12 changed files with 399 additions and 19 deletions

View File

@@ -424,6 +424,7 @@ def apply_pricing_rule(args, doc=None):
for item in item_list:
args_copy = copy.deepcopy(args)
args_copy.update(item)
set_transaction_type(args_copy)
data = get_pricing_rule_for_item(args_copy, doc=doc)
out.append(data)

View File

@@ -494,6 +494,7 @@ class SalesInvoice(SellingController):
self.validate_standalone_serial_nos_customer()
self.update_stock_reservation_entries()
self.update_stock_ledger()
self.validate_produced_serial_nos_against_reservation()
self.process_asset_depreciation()

View File

@@ -5042,6 +5042,34 @@ class TestSalesInvoice(FrappeTestCase):
frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center)
@change_settings("Stock Settings", {"enable_stock_reservation": 1})
def test_update_stock_restricted_to_reserved_produced_serial_nos(self):
from erpnext.selling.doctype.sales_order.sales_order import (
make_sales_invoice as make_si_from_so,
)
from erpnext.stock.doctype.delivery_note.test_delivery_note import (
make_so_with_reserved_produced_serial_no,
)
so, reserved, unreserved = make_so_with_reserved_produced_serial_no()
def make_si(serial_no):
si = make_si_from_so(so.name)
si.update_stock = 1
si.items[0].warehouse = so.items[0].warehouse
si.items[0].use_serial_batch_fields = 1
si.items[0].serial_no = serial_no
return si.save()
frappe.db.savepoint("unreserved_serial_no")
si = make_si(unreserved[0])
self.assertRaises(frappe.ValidationError, si.submit)
frappe.db.rollback(save_point="unreserved_serial_no")
si = make_si(reserved[0])
si.submit()
self.assertEqual(get_serial_nos_from_bundle(si.items[0].serial_and_batch_bundle), reserved)
def make_item_for_si(item_code, properties=None):
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -896,6 +896,77 @@ class SellingController(StockController):
title=_("Not Allowed"),
)
def validate_produced_serial_nos_against_reservation(self):
"""Restrict delivery to the serial nos reserved for a Sales Order Item with ensure delivery by serial no."""
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_serial_nos_for_voucher_detail_nos,
)
if self.is_return or not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
return
so_field = "sales_order" if self.doctype == "Sales Invoice" else "against_sales_order"
rows = [d for d in self.items if d.get(so_field) and d.so_detail]
if not rows:
return
flagged_so_details = frappe.get_all(
"Sales Order Item",
filters={
"name": ("in", [d.so_detail for d in rows]),
"ensure_delivery_based_on_produced_serial_no": 1,
},
pluck="name",
)
rows = [d for d in rows if d.so_detail in flagged_so_details]
if not rows:
return
reserved_serial_nos = get_sre_reserved_serial_nos_for_voucher_detail_nos(
"Sales Order", flagged_so_details
)
bundle_map = dict(
frappe.get_all(
rows[0].doctype,
filters={"name": ("in", [d.name for d in rows])},
fields=["name", "serial_and_batch_bundle"],
as_list=True,
)
)
bundle_serial_nos = frappe._dict()
if bundles := [b for b in bundle_map.values() if b]:
for entry in frappe.get_all(
"Serial and Batch Entry",
filters={"parent": ("in", bundles), "serial_no": ("is", "set")},
fields=["parent", "serial_no"],
):
bundle_serial_nos.setdefault(entry.parent, []).append(entry.serial_no)
for row in rows:
if not reserved_serial_nos.get(row.so_detail):
frappe.throw(
_(
"Row #{0}: Delivery of Item {1} is ensured by produced Serial No, but no Serial No is reserved against Sales Order {2}. Reserve the produced Serial Nos from the Sales Order."
).format(row.idx, frappe.bold(row.item_code), frappe.bold(row.get(so_field))),
title=_("Serial No Not Reserved"),
)
bundle = bundle_map.get(row.name)
serial_nos = bundle_serial_nos.get(bundle, []) if bundle else get_serial_nos(row.serial_no)
if invalid_serial_nos := [
sn for sn in serial_nos if sn not in reserved_serial_nos[row.so_detail]
]:
frappe.throw(
_(
"Row #{0}: Serial No {1} is not reserved against Sales Order {2}. Deliver only the Serial Nos produced and reserved for it."
).format(
row.idx, frappe.bold(", ".join(invalid_serial_nos)), frappe.bold(row.get(so_field))
),
title=_("Serial No Not Reserved"),
)
def update_stock_reservation_entries(self) -> None:
"""Updates Delivered Qty in Stock Reservation Entries."""

View File

@@ -6,6 +6,10 @@ erpnext.utils.CRMActivities = class CRMActivities {
refresh() {
var me = this;
$(this.open_activities_wrapper).empty();
// an unsaved doc has no activities and its temp name can't be permission-checked
if (this.frm.is_new()) return;
let cur_form_footer = this.form_wrapper.find(".form-footer");
// all activities

View File

@@ -1,6 +1,7 @@
from unittest import TestCase
import frappe
from frappe.tests.utils import change_settings
import erpnext
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
@@ -102,6 +103,106 @@ class TestUaeVat201(TestCase):
self.assertEqual(get_standard_rated_expenses_total(filters), 917.5)
self.assertEqual(get_standard_rated_expenses_tax(filters), 50)
@change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": True}
)
def test_uae_vat_201_sales_vat_in_foreign_currency(self):
"""VAT on a foreign currency invoice must be reported in company currency."""
si = create_sales_invoice(
company="_Test Company UAE VAT",
customer="_Test UAE Customer",
currency="USD",
conversion_rate=3.67,
rate=1000,
qty=1,
warehouse="Finished Goods - _TCUV",
debit_to="Debtors - _TCUV",
income_account="Sales - _TCUV",
expense_account="Cost of Goods Sold - _TCUV",
cost_center="Main - _TCUV",
item="_Test UAE VAT Item",
do_not_save=1,
)
si.vat_emirate = "Dubai"
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "VAT 5% - _TCUV",
"cost_center": "Main - _TCUV",
"description": "VAT 5% @ 5.0",
"rate": 5.0,
},
)
si.submit()
filters = {"company": "_Test Company UAE VAT"}
amounts_by_emirate = dict(
(emirate, (amount, vat)) for emirate, amount, vat in get_total_emiratewise(filters)
)
amount, vat = amounts_by_emirate["Dubai"]
self.assertEqual(amount, 3670)
self.assertEqual(vat, 183.5)
self.assertEqual(vat, si.taxes[0].base_tax_amount_after_discount_amount)
self.assertNotEqual(vat, si.items[0].tax_amount)
def test_uae_vat_201_mixed_invoice_excludes_exempt_and_zero_rated_vat(self):
si = create_sales_invoice(
company="_Test Company UAE VAT",
customer="_Test UAE Customer",
currency="AED",
rate=100,
qty=1,
warehouse="Finished Goods - _TCUV",
debit_to="Debtors - _TCUV",
income_account="Sales - _TCUV",
expense_account="Cost of Goods Sold - _TCUV",
cost_center="Main - _TCUV",
item="_Test UAE VAT Item",
do_not_save=1,
)
si.vat_emirate = "Ajman"
for item_code in ("_Test UAE VAT Zero Rated Item", "_Test UAE VAT Exempt Item"):
si.append(
"items",
{
"item_code": item_code,
"qty": 1,
"rate": 100,
"warehouse": "Finished Goods - _TCUV",
"income_account": "Sales - _TCUV",
"expense_account": "Cost of Goods Sold - _TCUV",
"cost_center": "Main - _TCUV",
},
)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "VAT 5% - _TCUV",
"cost_center": "Main - _TCUV",
"description": "VAT 5% @ 5.0",
"rate": 5.0,
},
)
si.submit()
# the single On Net Total row taxes all three items, so the invoice level figure is 15
self.assertEqual(si.taxes[0].base_tax_amount_after_discount_amount, 15)
filters = {"company": "_Test Company UAE VAT"}
amounts_by_emirate = dict(
(emirate, (amount, vat)) for emirate, amount, vat in get_total_emiratewise(filters)
)
amount, vat = amounts_by_emirate["Ajman"]
# only the standard rated row belongs in box 1
self.assertEqual(amount, 100)
self.assertEqual(vat, 5)
self.assertEqual(get_zero_rated_total(filters), 100)
self.assertEqual(get_exempt_total(filters), 100)
def make_company(company_name, abbr):
if not frappe.db.exists("Company", company_name):

View File

@@ -2,8 +2,13 @@
# For license information, please see license.txt
import json
from collections import defaultdict
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import flt
from erpnext import get_region
@@ -144,26 +149,79 @@ def append_data(data, no, legend, amount, vat_amount):
def get_total_emiratewise(filters):
"""Returns Emiratewise Amount and Taxes."""
conditions = get_conditions(filters)
try:
return frappe.db.sql(
f"""
select
s.vat_emirate as emirate, sum(i.base_net_amount) as total, sum(i.tax_amount)
from
`tabSales Invoice Item` i inner join `tabSales Invoice` s
on
i.parent = s.name
where
s.docstatus = 1 and i.is_exempt != 1 and i.is_zero_rated != 1
{conditions}
group by
s.vat_emirate;
""",
filters,
amounts = get_emiratewise_standard_rated_amount(filters)
vat_amounts = get_emiratewise_vat_amount(filters)
return [
(emirate, amounts.get(emirate, 0), vat_amounts.get(emirate, 0))
for emirate in dict.fromkeys([*amounts, *vat_amounts])
]
def get_emiratewise_standard_rated_amount(filters):
"""Returns emiratewise net amount of standard rated supplies in company currency."""
i = frappe.qb.DocType("Sales Invoice Item")
s = frappe.qb.DocType("Sales Invoice")
query = (
frappe.qb.from_(i)
.inner_join(s)
.on(i.parent == s.name)
.select(s.vat_emirate, Sum(i.base_net_amount))
.where((s.docstatus == 1) & (i.is_exempt != 1) & (i.is_zero_rated != 1))
.groupby(s.vat_emirate)
)
for condition in get_sales_conditions(filters, s):
query = query.where(condition)
return dict(query.run())
def get_emiratewise_vat_amount(filters):
"""Returns emiratewise VAT on standard rated supplies in company currency.
Sales Taxes and Charges.item_wise_tax_detail stores each item's share of the tax
row in company currency, so it keeps the item level exempt / zero rated split.
"""
i = frappe.qb.DocType("Sales Invoice Item")
s = frappe.qb.DocType("Sales Invoice")
t = frappe.qb.DocType("Sales Taxes and Charges")
uae_vat = frappe.qb.DocType("UAE VAT Account")
standard_rated_items_query = (
frappe.qb.from_(i)
.inner_join(s)
.on(i.parent == s.name)
.select(s.name, i.item_code)
.where((s.docstatus == 1) & (i.is_exempt != 1) & (i.is_zero_rated != 1))
)
for condition in get_sales_conditions(filters, s):
standard_rated_items_query = standard_rated_items_query.where(condition)
standard_rated_items = defaultdict(set)
for invoice, item_code in standard_rated_items_query.run():
standard_rated_items[invoice].add(item_code)
vat_query = (
frappe.qb.from_(t)
.inner_join(s)
.on(t.parent == s.name)
.select(s.name, s.vat_emirate, t.item_wise_tax_detail)
.where(
(s.docstatus == 1)
& t.account_head.isin(
frappe.qb.from_(uae_vat)
.select(uae_vat.account)
.where(uae_vat.parent == filters.get("company"))
)
)
except (IndexError, TypeError):
return 0
)
for condition in get_sales_conditions(filters, s):
vat_query = vat_query.where(condition)
vat_amounts = defaultdict(float)
for invoice, emirate, item_wise_tax_detail in vat_query.run():
for item_code, tax_detail in json.loads(item_wise_tax_detail or "{}").items():
if item_code in standard_rated_items[invoice]:
vat_amounts[emirate] += flt(tax_detail[1])
return vat_amounts
def get_emirates():
@@ -411,3 +469,15 @@ def get_conditions(filters):
if filters.get(opts[0]):
conditions += opts[1]
return conditions
def get_sales_conditions(filters, sales_invoice):
"""Return Query Builder conditions for Sales Invoice report filters."""
conditions = []
if filters.get("company"):
conditions.append(sales_invoice.company == filters.get("company"))
if filters.get("from_date"):
conditions.append(sales_invoice.posting_date >= filters.get("from_date"))
if filters.get("to_date"):
conditions.append(sales_invoice.posting_date <= filters.get("to_date"))
return conditions

View File

@@ -144,6 +144,11 @@ frappe.ui.form.on("Sales Order", {
frm.set_df_property("reserve_stock", "read_only", 1);
frm.set_df_property("reserve_stock", "hidden", 1);
frm.fields_dict.items.grid.update_docfield_property("reserve_stock", "hidden", 1);
frm.fields_dict.items.grid.update_docfield_property(
"ensure_delivery_based_on_produced_serial_no",
"hidden",
1
);
frm.fields_dict.items.grid.update_docfield_property(
"reserve_stock",
"default",

View File

@@ -759,6 +759,9 @@ class SalesOrder(SellingController):
if item.reserve_stock and (not enable_stock_reservation or not cint(item.is_stock_item)):
item.reserve_stock = 0
if item.ensure_delivery_based_on_produced_serial_no and not enable_stock_reservation:
item.ensure_delivery_based_on_produced_serial_no = 0
def has_unreserved_stock(self) -> bool:
"""Returns True if there is any unreserved item in the Sales Order."""

View File

@@ -507,6 +507,7 @@ class DeliveryNote(SellingController):
# Updating stock ledger should always be called after updating prevdoc status,
# because updating reserved qty in bin depends upon updated delivered qty in SO
self.update_stock_ledger()
self.validate_produced_serial_nos_against_reservation()
self.make_gl_entries()
self.repost_future_sle_and_gle()

View File

@@ -3111,6 +3111,74 @@ class TestDeliveryNote(FrappeTestCase):
dn.items[0].stock_qty = 2
dn.save()
@change_settings("Stock Settings", {"enable_stock_reservation": 1})
def test_delivery_restricted_to_reserved_produced_serial_nos(self):
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle
so, reserved, unreserved = make_so_with_reserved_produced_serial_no()
frappe.db.savepoint("unreserved_serial_no")
dn = make_delivery_note(so.name)
dn.items[0].use_serial_batch_fields = 1
dn.items[0].serial_no = unreserved[0]
dn.save()
self.assertRaises(frappe.ValidationError, dn.submit)
frappe.db.rollback(save_point="unreserved_serial_no")
dn = make_delivery_note(so.name)
dn.items[0].use_serial_batch_fields = 1
dn.items[0].serial_no = reserved[0]
dn.save()
dn.submit()
self.assertEqual(get_serial_nos_from_bundle(dn.items[0].serial_and_batch_bundle), reserved)
@change_settings("Stock Settings", {"enable_stock_reservation": 0})
def test_ensure_delivery_by_serial_no_cleared_without_stock_reservation(self):
item_code = make_item("Test Ensure Serial Without SRE", {"is_stock_item": 1, "has_serial_no": 1}).name
so = make_sales_order(item_code=item_code, qty=1, do_not_save=True)
so.items[0].ensure_delivery_based_on_produced_serial_no = 1
so.save()
self.assertEqual(so.items[0].ensure_delivery_based_on_produced_serial_no, 0)
def make_so_with_reserved_produced_serial_no():
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_serial_nos_for_voucher_detail_nos,
)
warehouse = "_Test Warehouse - _TC"
fg_item = make_item(
"Test Produced Serial FG", {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TPSFG-.####"}
).name
rm_item = make_item("Test Produced Serial RM", {"is_stock_item": 1}).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
make_stock_entry(item_code=fg_item, target=warehouse, qty=2, basic_rate=100)
so = make_sales_order(item_code=fg_item, qty=1, warehouse=warehouse, do_not_submit=True)
so.items[0].ensure_delivery_based_on_produced_serial_no = 1
so.items[0].reserve_stock = 1
so.submit()
so.create_stock_reservation_entries(
items_details=[{"sales_order_item": so.items[0].name, "warehouse": warehouse, "qty_to_reserve": 1}]
)
reserved = sorted(
get_sre_reserved_serial_nos_for_voucher_detail_nos("Sales Order", [so.items[0].name])[
so.items[0].name
]
)
unreserved = frappe.get_all(
"Serial No",
filters={"item_code": fg_item, "status": "Active", "name": ("not in", reserved)},
pluck="name",
)
return so, reserved, unreserved
def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note")

View File

@@ -770,6 +770,33 @@ def get_sre_reserved_serial_nos_details(
return frappe._dict(query.run())
def get_sre_reserved_serial_nos_for_voucher_detail_nos(voucher_type: str, voucher_detail_nos: list) -> dict:
"""Returns {voucher_detail_no: set of reserved Serial Nos}, including the delivered ones."""
sre = frappe.qb.DocType("Stock Reservation Entry")
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(sre)
.inner_join(sb_entry)
.on(sre.name == sb_entry.parent)
.select(sre.voucher_detail_no, sb_entry.serial_no)
.distinct()
.where(
(sre.docstatus == 1)
& (sre.voucher_type == voucher_type)
& (sre.voucher_detail_no.isin(voucher_detail_nos))
& (sre.reservation_based_on == "Serial and Batch")
& (sb_entry.serial_no.isnotnull())
)
)
reserved_serial_nos = {}
for voucher_detail_no, serial_no in query.run():
reserved_serial_nos.setdefault(voucher_detail_no, set()).add(serial_no)
return reserved_serial_nos
def get_sre_reserved_batch_nos_details(
item_code: str, warehouse: str, batch_nos: list | None = None, ignore_voucher_nos: list | None = None
) -> dict: