Merge branch 'develop' of https://github.com/frappe/erpnext into erpnext_form_cleanups

This commit is contained in:
Deepesh Garg
2026-02-04 11:57:05 +05:30
46 changed files with 761 additions and 284 deletions

View File

@@ -7,6 +7,7 @@ on:
paths:
- "**.js"
- "**.css"
- "**.svg"
- "**.md"
- "**.html"
- 'crowdin.yml'

View File

@@ -33,6 +33,17 @@
},
"account_number": "1151.000"
},
"Pajak Dibayar di Muka": {
"PPN Masukan": {
"account_number": "1152.001",
"account_type": "Tax"
},
"PPh 23 Dibayar di Muka": {
"account_number": "1152.002",
"account_type": "Tax"
},
"account_number": "1152.000"
},
"account_number": "1150.000"
},
"Kas": {
@@ -97,17 +108,6 @@
},
"account_number": "1130.000"
},
"Pajak Dibayar di Muka": {
"PPN Masukan": {
"account_number": "1151.001",
"account_type": "Tax"
},
"PPh 23 Dibayar di Muka": {
"account_number": "1152.001",
"account_type": "Tax"
},
"account_number": "1150.000"
},
"account_number": "1100.000"
},

View File

@@ -9,6 +9,7 @@
"engine": "InnoDB",
"field_order": [
"entry_type_and_date",
"company",
"is_system_generated",
"title",
"voucher_type",
@@ -17,7 +18,6 @@
"reversal_of",
"column_break1",
"from_template",
"company",
"posting_date",
"finance_book",
"apply_tds",
@@ -638,7 +638,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2025-11-13 17:54:14.542903",
"modified": "2026-02-03 14:40:39.944524",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",

View File

@@ -74,8 +74,8 @@ class JournalEntry(AccountsController):
mode_of_payment: DF.Link | None
multi_currency: DF.Check
naming_series: DF.Literal["ACC-JV-.YYYY.-"]
party_not_required: DF.Check
override_tax_withholding_entries: DF.Check
party_not_required: DF.Check
pay_to_recd_from: DF.Data | None
payment_order: DF.Link | None
periodic_entry_difference_account: DF.Link | None
@@ -1691,6 +1691,10 @@ def get_exchange_rate(
credit=None,
exchange_rate=None,
):
# Ensure exchange_rate is always numeric to avoid calculation errors
if isinstance(exchange_rate, str):
exchange_rate = flt(exchange_rate) or 1
account_details = frappe.get_cached_value(
"Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1
)

View File

@@ -1450,16 +1450,15 @@ frappe.ui.form.on("Payment Entry", {
callback: function (r) {
if (!r.exc && r.message) {
// set taxes table
if (r.message) {
for (let tax of r.message) {
if (tax.charge_type === "On Net Total") {
tax.charge_type = "On Paid Amount";
}
frm.add_child("taxes", tax);
let taxes = r.message;
taxes.forEach((tax) => {
if (tax.charge_type === "On Net Total") {
tax.charge_type = "On Paid Amount";
}
frm.events.apply_taxes(frm);
frm.events.set_unallocated_amount(frm);
}
});
frm.set_value("taxes", taxes);
frm.events.apply_taxes(frm);
frm.events.set_unallocated_amount(frm);
}
},
});

View File

@@ -897,6 +897,53 @@ class TestPOSInvoice(IntegrationTestCase):
if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
self.assertEqual(batch.qty, 5)
def test_pos_batch_reservation_with_return_qty(self):
"""
Test POS Invoice reserved qty for batch without bundle with return invoices.
"""
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_auto_batch_nos,
)
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_batch_item_with_batch,
)
create_batch_item_with_batch("_Batch Item Reserve Return", "TestBatch-RR 01")
se = make_stock_entry(
target="_Test Warehouse - _TC",
item_code="_Batch Item Reserve Return",
qty=30,
basic_rate=100,
)
se.reload()
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
# POS Invoice for the batch without bundle
pos_inv = create_pos_invoice(item="_Batch Item Reserve Return", rate=300, qty=15, do_not_save=1)
pos_inv.append(
"payments",
{"mode_of_payment": "Cash", "amount": 4500},
)
pos_inv.items[0].batch_no = batch_no
pos_inv.save()
pos_inv.submit()
# POS Invoice return
pos_return = make_sales_return(pos_inv.name)
pos_return.insert()
pos_return.submit()
batches = get_auto_batch_nos(
frappe._dict({"item_code": "_Batch Item Reserve Return", "warehouse": "_Test Warehouse - _TC"})
)
for batch in batches:
if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
self.assertEqual(batch.qty, 30)
def test_pos_batch_item_qty_validation(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
BatchNegativeStockError,

View File

@@ -98,8 +98,7 @@ def get_customers_list(pos_profile=None):
return (
frappe.db.sql(
f""" select name, customer_name, customer_group,
territory, customer_pos_id from tabCustomer where disabled = 0
f""" select name, customer_name, customer_group, territory from tabCustomer where disabled = 0
and {cond}""",
tuple(customer_groups),
as_dict=1,

View File

@@ -415,8 +415,9 @@ def reconcile(doc: None | str = None) -> None:
for x in allocations:
pr.append("allocation", x)
skip_ref_details_update_for_pe = check_multi_currency(pr)
# reconcile
pr.reconcile_allocations(skip_ref_details_update_for_pe=True)
pr.reconcile_allocations(skip_ref_details_update_for_pe=skip_ref_details_update_for_pe)
# If Payment Entry, update details only for newly linked references
# This is for performance
@@ -504,6 +505,37 @@ def reconcile(doc: None | str = None) -> None:
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")
def check_multi_currency(pr_doc):
GL = frappe.qb.DocType("GL Entry")
Account = frappe.qb.DocType("Account")
def get_account_currency(voucher_type, voucher_no):
currency = (
frappe.qb.from_(GL)
.join(Account)
.on(GL.account == Account.name)
.select(Account.account_currency)
.where(
(GL.voucher_type == voucher_type)
& (GL.voucher_no == voucher_no)
& (Account.account_type.isin(["Payable", "Receivable"]))
)
.limit(1)
).run(as_dict=True)
return currency[0].account_currency if currency else None
for allocation in pr_doc.allocation:
reference_currency = get_account_currency(allocation.reference_type, allocation.reference_name)
invoice_currency = get_account_currency(allocation.invoice_type, allocation.invoice_number)
if reference_currency != invoice_currency:
return True
return False
@frappe.whitelist()
def is_any_doc_running(for_filter: str | dict | None = None) -> str | None:
running_doc = None

View File

@@ -8,12 +8,12 @@
"email_append_to": 1,
"engine": "InnoDB",
"field_order": [
"company",
"title",
"naming_series",
"supplier",
"supplier_name",
"tax_id",
"company",
"column_break_6",
"posting_date",
"posting_time",
@@ -1668,7 +1668,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-01-29 21:21:53.051193",
"modified": "2026-02-03 14:23:47.937128",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -36,7 +36,7 @@ from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
from erpnext.controllers.buying_controller import BuyingController
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
update_billed_amount_based_on_po,
@@ -2005,9 +2005,17 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
args = json.loads(args)
def post_parent_process(source_parent, target_parent):
for row in target_parent.get("items"):
if row.get("qty") == 0:
target_parent.remove(row)
remove_items_with_zero_qty(target_parent)
set_missing_values(source_parent, target_parent)
def remove_items_with_zero_qty(target_parent):
target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0]
def set_missing_values(source_parent, target_parent):
target_parent.run_method("set_missing_values")
if args and args.get("merge_taxes"):
merge_taxes(source_parent, target_parent)
target_parent.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row
@@ -2059,7 +2067,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
"postprocess": update_item,
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",
"reset_value": not (args and args.get("merge_taxes")),
"ignore": args.get("merge_taxes") if args else 0,
},
},
target_doc,
post_parent_process,

View File

@@ -44,6 +44,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
"Unreconcile Payment Entries",
"Serial and Batch Bundle",
"Bank Transaction",
"Packing Slip",
];
if (!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {

View File

@@ -8,12 +8,12 @@
"engine": "InnoDB",
"field_order": [
"customer_section",
"company",
"company_tax_id",
"naming_series",
"customer",
"customer_name",
"tax_id",
"company",
"company_tax_id",
"column_break1",
"posting_date",
"posting_time",

View File

@@ -17,7 +17,7 @@
</div>
<div class="col-xs-6">
<table>
<tr><td><strong>Date: </strong></td><td>{{ frappe.utils.format_date(doc.creation) }}</td></tr>
<tr><td><strong>Date: </strong></td><td>{{ frappe.utils.format_date(doc.posting_date) }}</td></tr>
</table>
</div>
</div>

View File

@@ -176,7 +176,9 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
column_names = get_column_names()
# to display item as Item Code: Item Name
columns[0] = "Sales Invoice:Link/Item:300"
columns[0]["fieldname"] = "sales_invoice"
columns[0]["options"] = "Item"
columns[0]["width"] = 300
# removing Item Code and Item Name columns
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")

View File

@@ -163,11 +163,11 @@ def get_net_profit_loss(income, expense, period_list, company, currency=None, co
def get_chart_data(filters, columns, income, expense, net_profit_loss, currency):
labels = [d.get("label") for d in columns[2:]]
labels = [d.get("label") for d in columns[4:]]
income_data, expense_data, net_profit = [], [], []
for p in columns[2:]:
for p in columns[4:]:
if income:
income_data.append(income[-2].get(p.get("fieldname")))
if expense:

View File

@@ -9,10 +9,9 @@
"engine": "InnoDB",
"field_order": [
"supplier_section",
"company",
"title",
"naming_series",
"supplier",
"supplier_name",
"order_confirmation_no",
"order_confirmation_date",
"get_items_from_open_material_requests",
@@ -21,8 +20,9 @@
"transaction_date",
"schedule_date",
"column_break1",
"company",
"supplier",
"is_subcontracted",
"supplier_name",
"has_unit_price_items",
"supplier_warehouse",
"amended_from",
@@ -1310,7 +1310,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-01-29 21:22:54.323838",
"modified": "2026-02-03 14:44:55.192192",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -846,12 +846,34 @@ def get_filters(
if reference_voucher_detail_no:
filters["voucher_detail_no"] = reference_voucher_detail_no
if voucher_type in ["Purchase Receipt", "Purchase Invoice"] and item_row and item_row.get("warehouse"):
filters["warehouse"] = item_row.get("warehouse")
warehouses = []
if voucher_type in ["Purchase Receipt", "Purchase Invoice"] and item_row:
if reference_voucher_detail_no:
warehouses = get_warehouses_for_return(voucher_type, reference_voucher_detail_no)
if item_row.get("warehouse") and item_row.get("warehouse") in warehouses:
filters["warehouse"] = item_row.get("warehouse")
return filters
def get_warehouses_for_return(voucher_type, name):
warehouses = []
warehouse_details = frappe.get_all(
voucher_type + " Item",
filters={"name": name, "docstatus": 1},
fields=["warehouse", "rejected_warehouse"],
)
for d in warehouse_details:
if d.warehouse:
warehouses.append(d.warehouse)
if d.rejected_warehouse:
warehouses.append(d.rejected_warehouse)
return warehouses
def get_returned_serial_nos(child_doc, parent_doc, serial_no_field=None, ignore_voucher_detail_no=None):
from erpnext.stock.doctype.serial_no.serial_no import (
get_serial_nos as get_serial_nos_from_serial_no,

View File

@@ -296,7 +296,7 @@ class SellingController(StockController):
_(
"""Row #{0}: Selling rate for item {1} is lower than its {2}.
Selling {3} should be atleast {4}.<br><br>Alternatively,
you can disable selling price validation in {5} to bypass
you can disable '{5}' in {6} to bypass
this validation."""
).format(
idx,
@@ -304,7 +304,8 @@ class SellingController(StockController):
bold(ref_rate_field),
bold("net rate"),
bold(rate),
get_link_to_form("Selling Settings", "Selling Settings"),
bold(frappe.get_meta("Selling Settings").get_label("validate_selling_price")),
get_link_to_form("Selling Settings"),
),
title=_("Invalid Selling Price"),
)
@@ -313,7 +314,6 @@ class SellingController(StockController):
return
is_internal_customer = self.get("is_internal_customer")
valuation_rate_map = {}
for item in self.items:
if not item.item_code or item.is_free_item:
@@ -323,7 +323,9 @@ class SellingController(StockController):
"Item", item.item_code, ("last_purchase_rate", "is_stock_item")
)
last_purchase_rate_in_sales_uom = last_purchase_rate * (item.conversion_factor or 1)
last_purchase_rate_in_sales_uom = flt(
last_purchase_rate * (item.conversion_factor or 1), item.precision("base_net_rate")
)
if flt(item.base_net_rate) < flt(last_purchase_rate_in_sales_uom):
throw_message(item.idx, item.item_name, last_purchase_rate_in_sales_uom, "last purchase rate")
@@ -331,50 +333,16 @@ class SellingController(StockController):
if is_internal_customer or not is_stock_item:
continue
valuation_rate_map[(item.item_code, item.warehouse)] = None
if not valuation_rate_map:
return
or_conditions = (
f"""(item_code = {frappe.db.escape(valuation_rate[0])}
and warehouse = {frappe.db.escape(valuation_rate[1])})"""
for valuation_rate in valuation_rate_map
)
valuation_rates = frappe.db.sql(
f"""
select
item_code, warehouse, valuation_rate
from
`tabBin`
where
({" or ".join(or_conditions)})
and valuation_rate > 0
""",
as_dict=True,
)
for rate in valuation_rates:
valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate
for item in self.items:
if not item.item_code or item.is_free_item:
continue
last_valuation_rate = valuation_rate_map.get((item.item_code, item.warehouse))
if not last_valuation_rate:
continue
last_valuation_rate_in_sales_uom = last_valuation_rate * (item.conversion_factor or 1)
if flt(item.base_net_rate) < flt(last_valuation_rate_in_sales_uom):
if item.get("incoming_rate") and item.base_net_rate < (
valuation_rate := flt(
item.incoming_rate * (item.conversion_factor or 1), item.precision("base_net_rate")
)
):
throw_message(
item.idx,
item.item_name,
last_valuation_rate_in_sales_uom,
"valuation rate (Moving Average)",
valuation_rate,
"valuation rate",
)
def get_item_list(self):
@@ -533,6 +501,8 @@ class SellingController(StockController):
if self.doctype not in ("Delivery Note", "Sales Invoice"):
return
from erpnext.stock.serial_batch_bundle import get_batch_nos
allow_at_arms_length_price = frappe.get_cached_value(
"Stock Settings", None, "allow_internal_transfer_at_arms_length_price"
)
@@ -540,6 +510,7 @@ class SellingController(StockController):
"Selling Settings", "set_zero_rate_for_expired_batch"
)
old_doc = self.get_doc_before_save()
items = self.get("items") + (self.get("packed_items") or [])
for d in items:
if not frappe.get_cached_value("Item", d.item_code, "is_stock_item"):
@@ -569,6 +540,29 @@ class SellingController(StockController):
# Get incoming rate based on original item cost based on valuation method
qty = flt(d.get("stock_qty") or d.get("actual_qty") or d.get("qty"))
if old_doc:
old_item = next(
(
item
for item in (old_doc.get("items") + (old_doc.get("packed_items") or []))
if item.name == d.name
),
None,
)
if old_item:
old_qty = flt(
old_item.get("stock_qty") or old_item.get("actual_qty") or old_item.get("qty")
)
if (
old_item.item_code != d.item_code
or old_item.warehouse != d.warehouse
or old_qty != qty
or old_item.batch_no != d.batch_no
or get_batch_nos(old_item.serial_and_batch_bundle)
!= get_batch_nos(d.serial_and_batch_bundle)
):
d.incoming_rate = 0
if (
not d.incoming_rate
or self.is_internal_transfer()

View File

@@ -91,7 +91,8 @@ status_map = {
],
"Delivery Note": [
["Draft", None],
["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"],
["To Bill", "eval:self.per_billed == 0 and self.docstatus == 1"],
["Partially Billed", "eval:self.per_billed < 100 and self.per_billed > 0 and self.docstatus == 1"],
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
["Return", "eval:self.is_return == 1 and self.per_billed == 0 and self.docstatus == 1"],

View File

@@ -313,10 +313,10 @@ class SubcontractingController(StockController):
):
for row in frappe.get_all(
f"{self.subcontract_data.order_doctype} Item",
fields=["item_code", {"SUB": ["qty", "received_qty"], "as": "qty"}, "parent", "name"],
fields=["item_code", {"SUB": ["qty", "received_qty"], "as": "qty"}, "parent", "bom"],
filters={"docstatus": 1, "parent": ("in", self.subcontract_orders)},
):
self.qty_to_be_received[(row.item_code, row.parent)] += row.qty
self.qty_to_be_received[(row.item_code, row.parent, row.bom)] += row.qty
def __get_transferred_items(self):
se = frappe.qb.DocType("Stock Entry")
@@ -922,13 +922,17 @@ class SubcontractingController(StockController):
self.__set_serial_nos(item_row, rm_obj)
def __get_qty_based_on_material_transfer(self, item_row, transfer_item):
key = (item_row.item_code, item_row.get(self.subcontract_data.order_field))
key = (
item_row.item_code,
item_row.get(self.subcontract_data.order_field),
item_row.get("bom"),
)
if self.qty_to_be_received == item_row.qty:
return transfer_item.qty
if self.qty_to_be_received:
qty = (flt(item_row.qty) * flt(transfer_item.qty)) / flt(self.qty_to_be_received.get(key, 0))
if self.qty_to_be_received.get(key):
qty = (flt(item_row.qty) * flt(transfer_item.qty)) / flt(self.qty_to_be_received.get(key))
transfer_item.item_details.required_qty = transfer_item.qty
if transfer_item.serial_no or frappe.get_cached_value(
@@ -977,7 +981,11 @@ class SubcontractingController(StockController):
if self.qty_to_be_received:
self.qty_to_be_received[
(row.item_code, row.get(self.subcontract_data.order_field))
(
row.item_code,
row.get(self.subcontract_data.order_field),
row.get("bom"),
)
] -= row.qty
def __set_rate_for_serial_and_batch_bundle(self):

View File

@@ -461,3 +461,4 @@ erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
erpnext.patches.v16_0.migrate_transaction_deletion_task_flags_to_status # 2
erpnext.patches.v16_0.set_ordered_qty_in_quotation_item
erpnext.patches.v16_0.update_company_custom_field_in_bin

View File

@@ -0,0 +1,14 @@
import frappe
def execute():
frappe.reload_doc("stock", "doctype", "bin")
frappe.db.sql(
"""
UPDATE `tabBin` b
INNER JOIN `tabWarehouse` w ON b.warehouse = w.name
SET b.company = w.company
WHERE b.company IS NULL OR b.company = ''
"""
)

View File

@@ -308,6 +308,8 @@ class Project(Document):
self.gross_margin = flt(self.total_billed_amount) - expense_amount
if self.total_billed_amount:
self.per_gross_margin = (self.gross_margin / flt(self.total_billed_amount)) * 100
else:
self.per_gross_margin = 0
def update_purchase_costing(self):
total_purchase_cost = calculate_total_purchase_cost(self.name)

View File

Before

Width:  |  Height:  |  Size: 787 B

After

Width:  |  Height:  |  Size: 787 B

View File

Before

Width:  |  Height:  |  Size: 809 B

After

Width:  |  Height:  |  Size: 809 B

View File

@@ -625,6 +625,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
callback: function (r) {
if (!r.exc) {
me.frm.refresh_fields();
me.show_batch_dialog_if_required(item);
}
},
});
@@ -635,26 +636,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
process_item_selection(doc, cdt, cdn) {
var item = frappe.get_doc(cdt, cdn);
let update_stock = 0;
var me = this;
var update_stock = 0,
show_batch_dialog = 0;
item.weight_per_unit = 0;
item.weight_uom = "";
item.uom = null; // make UOM blank to update the existing UOM when item changes
item.conversion_factor = 0;
if (["Sales Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) {
update_stock = cint(me.frm.doc.update_stock);
show_batch_dialog = update_stock;
} else if (this.frm.doc.doctype === "Purchase Receipt" || this.frm.doc.doctype === "Delivery Note") {
show_batch_dialog = 1;
}
if (show_batch_dialog && item.use_serial_batch_fields === 1) {
show_batch_dialog = 0;
}
item.barcode = null;
if (item.item_code || item.serial_no) {
@@ -765,74 +753,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
},
() => me.toggle_conversion_factor(item),
() => {
if (show_batch_dialog && !frappe.flags.trigger_from_barcode_scanner)
return frappe.db
.get_value("Item", item.item_code, [
"has_batch_no",
"has_serial_no",
])
.then((r) => {
if (
r.message &&
(r.message.has_batch_no || r.message.has_serial_no)
) {
frappe.flags.hide_serial_batch_dialog = false;
} else {
show_batch_dialog = false;
}
});
},
() => {
// check if batch serial selector is disabled or not
if (show_batch_dialog && !frappe.flags.hide_serial_batch_dialog)
return frappe.db
.get_single_value(
"Stock Settings",
"disable_serial_no_and_batch_selector"
)
.then((value) => {
if (value) {
frappe.flags.hide_serial_batch_dialog = true;
}
});
},
() => {
if (
show_batch_dialog &&
!frappe.flags.hide_serial_batch_dialog &&
!frappe.flags.dialog_set
) {
var d = locals[cdt][cdn];
$.each(r.message, function (k, v) {
if (!d[k]) d[k] = v;
});
if (d.has_batch_no && d.has_serial_no) {
d.batch_no = undefined;
}
frappe.flags.dialog_set = true;
erpnext.show_serial_batch_selector(
me.frm,
d,
(item) => {
me.frm.script_manager.trigger("qty", item.doctype, item.name);
if (!me.frm.doc.set_warehouse)
me.frm.script_manager.trigger(
"warehouse",
item.doctype,
item.name
);
me.apply_price_list(item, true);
},
undefined,
!frappe.flags.hide_serial_batch_dialog
);
} else {
frappe.flags.dialog_set = false;
}
},
() => me.show_batch_dialog_if_required(item),
() => me.conversion_factor(doc, cdt, cdn, true),
() => me.remove_pricing_rule(item),
() => {
@@ -853,6 +774,78 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
}
show_batch_dialog_if_required(item) {
let show_batch_dialog = 0;
let update_stock = 0;
let me = this;
if (!item.item_code) {
return;
}
if (["Sales Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) {
update_stock = cint(me.frm.doc.update_stock);
show_batch_dialog = update_stock;
} else if (this.frm.doc.doctype === "Purchase Receipt" || this.frm.doc.doctype === "Delivery Note") {
show_batch_dialog = 1;
}
if (show_batch_dialog && item.use_serial_batch_fields === 1) {
show_batch_dialog = 0;
}
frappe.run_serially([
() => {
if (show_batch_dialog && !frappe.flags.trigger_from_barcode_scanner)
return frappe.db
.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"])
.then((r) => {
if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
frappe.flags.hide_serial_batch_dialog = false;
} else {
show_batch_dialog = false;
}
});
},
() => {
// check if batch serial selector is disabled or not
if (show_batch_dialog && !frappe.flags.hide_serial_batch_dialog)
return frappe.db
.get_single_value("Stock Settings", "disable_serial_no_and_batch_selector")
.then((value) => {
if (value) {
frappe.flags.hide_serial_batch_dialog = true;
}
});
},
() => {
if (show_batch_dialog && !frappe.flags.hide_serial_batch_dialog && !frappe.flags.dialog_set) {
if (item.has_batch_no && item.has_serial_no) {
item.batch_no = undefined;
}
frappe.flags.dialog_set = true;
erpnext.show_serial_batch_selector(
me.frm,
item,
(item) => {
me.frm.script_manager.trigger("qty", item.doctype, item.name);
if (!me.frm.doc.set_warehouse)
me.frm.script_manager.trigger("warehouse", item.doctype, item.name);
me.apply_price_list(item, true);
},
undefined,
!frappe.flags.hide_serial_batch_dialog
);
} else {
frappe.flags.dialog_set = false;
}
},
]);
}
price_list_rate(doc, cdt, cdn) {
var item = frappe.get_doc(cdt, cdn);
frappe.model.round_floats_in(item, ["price_list_rate", "discount_percentage"]);

View File

@@ -994,7 +994,7 @@ erpnext.utils.map_current_doc = function (opts) {
if (opts.source_doctype) {
let data_fields = [];
if (["Purchase Receipt", "Delivery Note"].includes(opts.source_doctype)) {
if (["Purchase Receipt", "Delivery Note", "Purchase Invoice"].includes(opts.source_doctype)) {
let target_meta = frappe.get_meta(cur_frm.doc.doctype);
if (target_meta.fields.find((f) => f.fieldname === "taxes")) {
data_fields.push({

View File

@@ -110,12 +110,6 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm
options: "Country",
mandatory_depends_on: "eval:doc.city || doc.address_line1",
},
{
label: __("Customer POS Id"),
fieldname: "customer_pos_id",
fieldtype: "Data",
hidden: 1,
},
];
return variant_fields;

View File

@@ -142,7 +142,14 @@ def download_zip(files, output_filename):
def get_invoice_summary(items, taxes, item_wise_tax_details):
summary_data = frappe._dict()
taxes_wise_tax_details = {d.tax_row: d for d in item_wise_tax_details}
taxes_wise_tax_details = {}
for d in item_wise_tax_details:
if d.tax_row not in taxes_wise_tax_details:
taxes_wise_tax_details[d.tax_row] = []
taxes_wise_tax_details[d.tax_row].append(d)
for tax in taxes:
# Include only VAT charges.
if tax.charge_type == "Actual":

View File

@@ -113,12 +113,37 @@ class Customer(TransactionBase):
def get_customer_name(self):
self.customer_name = self.customer_name.strip()
if frappe.db.get_value("Customer", self.customer_name) and not frappe.flags.in_import:
count = frappe.db.sql(
"""select ifnull(MAX(CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED)), 0) from tabCustomer
where name like %s""",
f"%{self.customer_name} - %",
as_list=1,
)[0][0]
name_prefix = f"{self.customer_name} - %"
if frappe.db.db_type == "postgres":
# Postgres: extract trailing digits (e.g. "Customer - 3") and cast to int.
# NOTE: PostgreSQL is strict about types; MySQL's UNSIGNED cast does not exist.
count = frappe.db.sql(
"""
SELECT COALESCE(
MAX(CAST(SUBSTRING(name FROM '\\d+$') AS INTEGER)),
0
)
FROM tabCustomer
WHERE name LIKE %(name_prefix)s
""",
{"name_prefix": name_prefix},
as_list=1,
)[0][0]
else:
# MariaDB/MySQL: keep existing behavior.
count = frappe.db.sql(
"""
SELECT COALESCE(
MAX(CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED)),
0
)
FROM tabCustomer
WHERE name LIKE %(name_prefix)s
""",
{"name_prefix": name_prefix},
as_list=1,
)[0][0]
count = cint(count) + 1
new_customer_name = f"{self.customer_name} - {cstr(count)}"
@@ -506,6 +531,9 @@ def _set_missing_values(source, target):
if contact:
target.contact_person = contact[0].parent
target.contact_display, target.contact_email, target.contact_mobile = frappe.get_value(
"Contact", contact[0].parent, ["full_name", "email_id", "mobile_no"]
)
@frappe.whitelist()

View File

@@ -11,18 +11,18 @@
"field_order": [
"customer_section",
"column_break0",
"company",
"naming_series",
"customer",
"customer_name",
"tax_id",
"order_type",
"column_break_7",
"transaction_date",
"delivery_date",
"column_break1",
"customer",
"customer_name",
"tax_id",
"po_no",
"po_date",
"company",
"skip_delivery_note",
"has_unit_price_items",
"is_subcontracted",
@@ -1713,7 +1713,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-01-29 21:23:48.362401",
"modified": "2026-02-03 14:45:50.314361",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",

View File

@@ -22,6 +22,7 @@
"reserved_stock",
"section_break_pmrs",
"stock_uom",
"company",
"column_break_0slj",
"valuation_rate",
"stock_value"
@@ -132,6 +133,14 @@
"options": "UOM",
"read_only": 1
},
{
"fetch_from": "warehouse.company",
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"read_only": 1
},
{
"fieldname": "valuation_rate",
"fieldtype": "Float",
@@ -186,7 +195,7 @@
"idx": 1,
"in_create": 1,
"links": [],
"modified": "2024-03-27 13:06:39.414036",
"modified": "2026-02-01 08:11:46.824913",
"modified_by": "Administrator",
"module": "Stock",
"name": "Bin",
@@ -231,8 +240,9 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "item_code,warehouse",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -19,6 +19,7 @@ class Bin(Document):
from frappe.types import DF
actual_qty: DF.Float
company: DF.Link | None
indented_qty: DF.Float
item_code: DF.Link
ordered_qty: DF.Float

View File

@@ -1070,7 +1070,7 @@
"no_copy": 1,
"oldfieldname": "status",
"oldfieldtype": "Select",
"options": "\nDraft\nTo Bill\nCompleted\nReturn\nReturn Issued\nCancelled\nClosed",
"options": "\nDraft\nTo Bill\nPartially Billed\nCompleted\nReturn\nReturn Issued\nCancelled\nClosed",
"print_hide": 1,
"print_width": "150px",
"read_only": 1,
@@ -1434,7 +1434,7 @@
"idx": 146,
"is_submittable": 1,
"links": [],
"modified": "2026-01-29 21:24:11.781261",
"modified": "2026-02-03 12:27:19.055918",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note",

View File

@@ -127,7 +127,15 @@ class DeliveryNote(SellingController):
shipping_address_name: DF.Link | None
shipping_rule: DF.Link | None
status: DF.Literal[
"", "Draft", "To Bill", "Completed", "Return", "Return Issued", "Cancelled", "Closed"
"",
"Draft",
"To Bill",
"Partially Billed",
"Completed",
"Return",
"Return Issued",
"Cancelled",
"Closed",
]
tax_category: DF.Link | None
tax_id: DF.Data | None

View File

@@ -18,8 +18,10 @@ frappe.listview_settings["Delivery Note"] = {
return [__("Closed"), "green", "status,=,Closed"];
} else if (doc.status === "Return Issued") {
return [__("Return Issued"), "grey", "status,=,Return Issued"];
} else if (flt(doc.per_billed, 2) < 100) {
return [__("To Bill"), "orange", "per_billed,<,100|docstatus,=,1"];
} else if (flt(doc.per_billed) == 0) {
return [__("To Bill"), "orange", "per_billed,=,0|docstatus,=,1"];
} else if (flt(doc.per_billed, 2) > 0 && flt(doc.per_billed, 2) < 100) {
return [__("Partially Billed"), "yellow", "per_billed,<,100|docstatus,=,1"];
} else if (flt(doc.per_billed, 2) === 100) {
return [__("Completed"), "green", "per_billed,=,100|docstatus,=,1"];
}

View File

@@ -1101,7 +1101,8 @@ class TestDeliveryNote(IntegrationTestCase):
self.assertEqual(dn2.get("items")[0].billed_amt, 400)
self.assertEqual(dn2.per_billed, 80)
self.assertEqual(dn2.status, "To Bill")
# Since 20% of DN2 is yet to be billed, it should be classified as partially billed.
self.assertEqual(dn2.status, "Partially Billed")
def test_dn_billing_status_case4(self):
# SO -> SI -> DN
@@ -2863,6 +2864,23 @@ class TestDeliveryNote(IntegrationTestCase):
for entry in sabb.entries:
self.assertEqual(entry.incoming_rate, 200)
@IntegrationTestCase.change_settings("Selling Settings", {"validate_selling_price": 1})
def test_validate_selling_price(self):
item_code = make_item("VSP Item", properties={"is_stock_item": 1}).name
make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=10)
make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=1)
dn = create_delivery_note(
item_code=item_code,
qty=1,
rate=9,
do_not_save=True,
)
self.assertRaises(frappe.ValidationError, dn.save)
dn.items[0].incoming_rate = 0
dn.items[0].stock_qty = 2
dn.save()
def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note")

View File

@@ -5111,6 +5111,128 @@ class TestPurchaseReceipt(IntegrationTestCase):
self.assertEqual(stk_ledger.incoming_rate, 120)
self.assertEqual(stk_ledger.stock_value_difference, 600)
def test_negative_stock_error_for_purchase_return_when_stock_exists_in_future_date(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.stock_ledger import NegativeStockError
item_code = make_item(
"Test Negative Stock for Purchase Return with Future Stock Item",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TNSPFPRI.#####",
},
).name
make_purchase_receipt(
item_code=item_code,
posting_date=add_days(today(), -4),
qty=100,
rate=100,
warehouse="_Test Warehouse - _TC",
)
pr1 = make_purchase_receipt(
item_code=item_code,
posting_date=add_days(today(), -3),
qty=100,
rate=100,
warehouse="_Test Warehouse - _TC",
)
batch1 = get_batch_from_bundle(pr1.items[0].serial_and_batch_bundle)
pr2 = make_purchase_receipt(
item_code=item_code,
posting_date=add_days(today(), -2),
qty=100,
rate=100,
warehouse="_Test Warehouse - _TC",
)
batch2 = get_batch_from_bundle(pr2.items[0].serial_and_batch_bundle)
make_stock_entry(
item_code=item_code,
qty=100,
posting_date=add_days(today(), -1),
source="_Test Warehouse - _TC",
target="_Test Warehouse 1 - _TC",
batch_no=batch1,
use_serial_batch_fields=1,
)
make_stock_entry(
item_code=item_code,
qty=100,
posting_date=add_days(today(), -1),
source="_Test Warehouse - _TC",
target="_Test Warehouse 1 - _TC",
batch_no=batch2,
use_serial_batch_fields=1,
)
make_stock_entry(
item_code=item_code,
qty=100,
posting_date=today(),
source="_Test Warehouse 1 - _TC",
target="_Test Warehouse - _TC",
batch_no=batch1,
use_serial_batch_fields=1,
)
make_purchase_entry = make_return_doc("Purchase Receipt", pr1.name)
make_purchase_entry.set_posting_time = 1
make_purchase_entry.posting_date = pr1.posting_date
self.assertRaises(NegativeStockError, make_purchase_entry.submit)
def test_purchase_return_from_different_warehouse(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = make_item(
"Test Purchase Return From Different Warehouse Item",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TPRFDWU.#####",
},
).name
pr1 = make_purchase_receipt(
item_code=item_code,
posting_date=add_days(today(), -4),
qty=100,
rate=100,
warehouse="_Test Warehouse - _TC",
)
batch1 = get_batch_from_bundle(pr1.items[0].serial_and_batch_bundle)
make_stock_entry(
item_code=item_code,
qty=100,
posting_date=add_days(today(), -1),
source="_Test Warehouse - _TC",
target="_Test Warehouse 1 - _TC",
batch_no=batch1,
use_serial_batch_fields=1,
)
make_purchase_entry = make_return_doc("Purchase Receipt", pr1.name)
make_purchase_entry.items[0].warehouse = "_Test Warehouse 1 - _TC"
make_purchase_entry.submit()
make_purchase_entry.reload()
sabb = frappe.get_doc("Serial and Batch Bundle", make_purchase_entry.items[0].serial_and_batch_bundle)
for row in sabb.entries:
self.assertEqual(row.warehouse, "_Test Warehouse 1 - _TC")
self.assertEqual(row.incoming_rate, 100)
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -17,6 +17,7 @@ from frappe.utils import (
cint,
cstr,
flt,
get_datetime,
get_link_to_form,
getdate,
now,
@@ -438,6 +439,8 @@ class SerialandBatchBundle(Document):
)
def get_valuation_rate_for_return_entry(self, return_against):
from erpnext.controllers.sales_and_purchase_return import get_warehouses_for_return
if not self.voucher_detail_no:
return {}
@@ -467,9 +470,11 @@ class SerialandBatchBundle(Document):
["Serial and Batch Bundle", "voucher_detail_no", "=", return_against_voucher_detail_no],
]
# Added to handle rejected warehouse case
if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]:
# Added to handle rejected warehouse case
filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse])
warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no)
if self.warehouse in warehouses:
filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse])
bundle_data = frappe.get_all(
"Serial and Batch Bundle",
@@ -1451,31 +1456,44 @@ class SerialandBatchBundle(Document):
for d in self.entries:
available_qty = batch_wise_available_qty.get(d.batch_no, 0)
if flt(available_qty, precision) < 0:
frappe.throw(
_(
"""
The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry."""
).format(
bold(d.batch_no),
bold(self.item_code),
bold(self.warehouse),
bold(abs(flt(available_qty, precision))),
),
title=_("Negative Stock Error"),
)
self.throw_negative_batch(d.batch_no, available_qty, precision)
def throw_negative_batch(self, batch_no, available_qty, precision):
from erpnext.stock.stock_ledger import NegativeStockError
frappe.throw(
_(
"""
The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry."""
).format(
bold(batch_no),
bold(self.item_code),
bold(self.warehouse),
bold(abs(flt(available_qty, precision))),
),
title=_("Negative Stock Error"),
exc=NegativeStockError,
)
def get_batchwise_available_qty(self):
available_qty = self.get_available_qty_from_sabb()
available_qty_from_ledger = self.get_available_qty_from_stock_ledger()
batchwise_entries = self.get_available_qty_from_sabb()
batchwise_entries.extend(self.get_available_qty_from_stock_ledger())
if not available_qty_from_ledger:
return available_qty
available_qty = frappe._dict({})
batchwise_entries = sorted(
batchwise_entries,
key=lambda x: (get_datetime(x.get("posting_datetime")), get_datetime(x.get("creation"))),
)
for batch_no, qty in available_qty_from_ledger.items():
if batch_no in available_qty:
available_qty[batch_no] += qty
precision = frappe.get_precision("Serial and Batch Entry", "qty")
for row in batchwise_entries:
if row.batch_no in available_qty:
available_qty[row.batch_no] += flt(row.qty)
else:
available_qty[batch_no] = qty
available_qty[row.batch_no] = flt(row.qty)
if flt(available_qty[row.batch_no], precision) < 0:
self.throw_negative_batch(row.batch_no, available_qty[row.batch_no], precision)
return available_qty
@@ -1488,7 +1506,9 @@ class SerialandBatchBundle(Document):
frappe.qb.from_(sle)
.select(
sle.batch_no,
Sum(sle.actual_qty).as_("available_qty"),
sle.actual_qty.as_("qty"),
sle.posting_datetime,
sle.creation,
)
.where(
(sle.item_code == self.item_code)
@@ -1500,12 +1520,9 @@ class SerialandBatchBundle(Document):
& (sle.batch_no.isnotnull())
)
.for_update()
.groupby(sle.batch_no)
)
res = query.run(as_list=True)
return frappe._dict(res) if res else frappe._dict()
return query.run(as_dict=True)
def get_available_qty_from_sabb(self):
batches = [d.batch_no for d in self.entries if d.batch_no]
@@ -1516,7 +1533,9 @@ class SerialandBatchBundle(Document):
frappe.qb.from_(child)
.select(
child.batch_no,
Sum(child.qty).as_("available_qty"),
child.qty,
child.posting_datetime,
child.creation,
)
.where(
(child.item_code == self.item_code)
@@ -1527,13 +1546,10 @@ class SerialandBatchBundle(Document):
& (child.type_of_transaction.isin(["Inward", "Outward"]))
)
.for_update()
.groupby(child.batch_no)
)
query = query.where(child.voucher_type != "Pick List")
res = query.run(as_list=True)
return frappe._dict(res) if res else frappe._dict()
return query.run(as_dict=True)
def validate_voucher_no_docstatus(self):
if self.voucher_type == "POS Invoice":
@@ -2596,11 +2612,11 @@ def get_reserved_batches_for_pos(kwargs) -> dict:
key = (row.batch_no, row.warehouse)
if key in pos_batches:
pos_batches[key]["qty"] -= row.qty * -1 if row.is_return else row.qty
pos_batches[key]["qty"] += row.qty * -1
else:
pos_batches[key] = frappe._dict(
{
"qty": (row.qty * -1 if not row.is_return else row.qty),
"qty": row.qty * -1,
"warehouse": row.warehouse,
}
)

View File

@@ -1271,15 +1271,11 @@ def get_items(warehouse, posting_date, posting_time, company, item_code=None, ig
for d in items:
if (d.item_code, d.warehouse) in itemwise_batch_data:
valuation_rate = get_stock_balance(
d.item_code, d.warehouse, posting_date, posting_time, with_valuation_rate=True
)[1]
for row in itemwise_batch_data.get((d.item_code, d.warehouse)):
if ignore_empty_stock and not row.qty:
continue
args = get_item_data(row, row.qty, valuation_rate)
args = get_item_data(row, row.qty, row.valuation_rate)
res.append(args)
else:
stock_bal = get_stock_balance(
@@ -1413,6 +1409,7 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None):
"item_code": row[0],
"warehouse": row[3],
"qty": row[8],
"valuation_rate": row[9],
"item_name": row[1],
"batch_no": row[4],
}

View File

@@ -282,7 +282,11 @@ class StockBalanceReport:
for field in self.inventory_dimensions:
qty_dict[field] = entry.get(field)
if entry.voucher_type == "Stock Reconciliation" and (not entry.batch_no or entry.serial_no):
if (
entry.voucher_type == "Stock Reconciliation"
and frappe.get_cached_value(entry.voucher_type, entry.voucher_no, "purpose") != "Opening Stock"
and (not entry.batch_no or entry.serial_no)
):
qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty)
else:
qty_diff = flt(entry.actual_qty)

View File

@@ -3,6 +3,7 @@
import frappe
from frappe.query_builder.functions import Coalesce, Sum
from frappe.utils import cstr, flt, now, nowdate, nowtime
from erpnext.controllers.stock_controller import create_repost_item_valuation_entry
@@ -182,18 +183,67 @@ def get_indented_qty(item_code, warehouse):
def get_ordered_qty(item_code, warehouse):
ordered_qty = frappe.db.sql(
"""
select sum((po_item.qty - po_item.received_qty)*po_item.conversion_factor)
from `tabPurchase Order Item` po_item, `tabPurchase Order` po
where po_item.item_code=%s and po_item.warehouse=%s
and po_item.qty > po_item.received_qty and po_item.parent=po.name
and po.status not in ('Closed', 'Delivered') and po.docstatus=1
and po_item.delivered_by_supplier = 0""",
(item_code, warehouse),
"""Return total pending ordered quantity for an item in a warehouse.
Includes outstanding quantities from Purchase Orders and Subcontracting Orders"""
purchase_order_qty = get_purchase_order_qty(item_code, warehouse)
subcontracting_order_qty = get_subcontracting_order_qty(item_code, warehouse)
return flt(purchase_order_qty) + flt(subcontracting_order_qty)
def get_purchase_order_qty(item_code, warehouse):
PurchaseOrder = frappe.qb.DocType("Purchase Order")
PurchaseOrderItem = frappe.qb.DocType("Purchase Order Item")
purchase_order_qty = (
frappe.qb.from_(PurchaseOrderItem)
.join(PurchaseOrder)
.on(PurchaseOrderItem.parent == PurchaseOrder.name)
.select(
Sum(
(PurchaseOrderItem.qty - PurchaseOrderItem.received_qty) * PurchaseOrderItem.conversion_factor
)
)
.where(
(PurchaseOrderItem.item_code == item_code)
& (PurchaseOrderItem.warehouse == warehouse)
& (PurchaseOrderItem.qty > PurchaseOrderItem.received_qty)
& (PurchaseOrder.status.notin(["Closed", "Delivered"]))
& (PurchaseOrder.docstatus == 1)
& (Coalesce(PurchaseOrderItem.delivered_by_supplier, 0) == 0)
)
.run()
)
return flt(ordered_qty[0][0]) if ordered_qty else 0
return purchase_order_qty[0][0] if purchase_order_qty else 0
def get_subcontracting_order_qty(item_code, warehouse):
SubcontractingOrder = frappe.qb.DocType("Subcontracting Order")
SubcontractingOrderItem = frappe.qb.DocType("Subcontracting Order Item")
subcontracting_order_qty = (
frappe.qb.from_(SubcontractingOrderItem)
.join(SubcontractingOrder)
.on(SubcontractingOrderItem.parent == SubcontractingOrder.name)
.select(
Sum(
(SubcontractingOrderItem.qty - SubcontractingOrderItem.received_qty)
* SubcontractingOrderItem.conversion_factor
)
)
.where(
(SubcontractingOrderItem.item_code == item_code)
& (SubcontractingOrderItem.warehouse == warehouse)
& (SubcontractingOrderItem.qty > SubcontractingOrderItem.received_qty)
& (SubcontractingOrder.status.notin(["Closed", "Completed"]))
& (SubcontractingOrder.docstatus == 1)
)
.run()
)
return subcontracting_order_qty[0][0] if subcontracting_order_qty else 0
def get_planned_qty(item_code, warehouse):

View File

@@ -12,7 +12,7 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor
StockReservation,
has_reserved_stock,
)
from erpnext.stock.stock_balance import update_bin_qty
from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty
from erpnext.stock.utils import get_bin
@@ -234,30 +234,7 @@ class SubcontractingOrder(SubcontractingController):
):
item_wh_list.append([item.item_code, item.warehouse])
for item_code, warehouse in item_wh_list:
update_bin_qty(item_code, warehouse, {"ordered_qty": self.get_ordered_qty(item_code, warehouse)})
@staticmethod
def get_ordered_qty(item_code, warehouse):
table = frappe.qb.DocType("Subcontracting Order")
child = frappe.qb.DocType("Subcontracting Order Item")
query = (
frappe.qb.from_(table)
.inner_join(child)
.on(table.name == child.parent)
.select((child.qty - child.received_qty) * child.conversion_factor)
.where(
(table.docstatus == 1)
& (child.item_code == item_code)
& (child.warehouse == warehouse)
& (child.qty > child.received_qty)
& (table.status != "Completed")
)
)
query = query.run()
return flt(query[0][0]) if query else 0
update_bin_qty(item_code, warehouse, {"ordered_qty": get_ordered_qty(item_code, warehouse)})
def update_reserved_qty_for_subcontracting(self, sco_item_rows=None):
for item in self.supplied_items:

View File

@@ -616,6 +616,117 @@ class TestSubcontractingReceipt(IntegrationTestCase):
for item in scr.supplied_items:
self.assertFalse(item.available_qty_for_consumption)
def test_supplied_items_consumed_qty_for_similar_finished_goods(self):
"""
Test that supplied raw material consumption is calculated correctly
when multiple subcontracted service items use the same finished good
but different BOMs.
"""
from erpnext.controllers.subcontracting_controller import (
make_rm_stock_entry as make_subcontract_transfer_entry,
)
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
# Configuration: Backflush based on subcontract material transfer
set_backflush_based_on("Material Transferred for Subcontract")
# Create Raw Materials
raw_material_1 = make_item("_RM Item 1", properties={"is_stock_item": 1}).name
raw_material_2 = make_item("_RM Item 2", properties={"is_stock_item": 1}).name
# Create Subcontracted Finished Good
finished_good = make_item("_Finished Good Item", properties={"is_stock_item": 1})
finished_good.is_sub_contracted_item = 1
finished_good.save()
# Receive Raw Materials into Warehouse
for raw_material in (raw_material_1, raw_material_2):
make_stock_entry(
item_code=raw_material,
qty=10,
target="_Test Warehouse - _TC",
basic_rate=100,
)
# Create BOMs for the same Finished Good with different RMs
bom_rm_1 = make_bom(
item=finished_good.name,
quantity=1,
raw_materials=[raw_material_1],
).name
_bom_rm_2 = make_bom(
item=finished_good.name,
quantity=1,
raw_materials=[raw_material_2],
).name
# Define Subcontracted Service Items
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 1",
"qty": 1,
"rate": 100,
"fg_item": finished_good.name,
"fg_item_qty": 10,
},
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 1",
"qty": 1,
"rate": 150,
"fg_item": finished_good.name,
"fg_item_qty": 10,
},
]
# Create Subcontracting Order
subcontracting_order = get_subcontracting_order(
service_items=service_items,
do_not_save=True,
)
# Assign BOM only to the first service item
subcontracting_order.items[0].bom = bom_rm_1
subcontracting_order.save()
subcontracting_order.submit()
# Prepare Raw Material Transfer Items
raw_material_transfer_items = []
for supplied_item in subcontracting_order.supplied_items:
raw_material_transfer_items.append(
{
"item_code": supplied_item.main_item_code,
"rm_item_code": supplied_item.rm_item_code,
"qty": supplied_item.required_qty,
"warehouse": "_Test Warehouse - _TC",
"stock_uom": "Nos",
}
)
# Transfer Raw Materials to Subcontractor Warehouse
stock_entry = frappe.get_doc(
make_subcontract_transfer_entry(
subcontracting_order.name,
raw_material_transfer_items,
)
)
stock_entry.to_warehouse = "_Test Warehouse 1 - _TC"
stock_entry.save()
stock_entry.submit()
# Create Subcontracting Receipt
subcontracting_receipt = make_subcontracting_receipt(subcontracting_order.name)
subcontracting_receipt.save()
# Check consumed_qty for each supplied item
self.assertEqual(len(subcontracting_receipt.supplied_items), 2)
self.assertEqual(subcontracting_receipt.supplied_items[0].consumed_qty, 10)
self.assertEqual(subcontracting_receipt.supplied_items[1].consumed_qty, 10)
def test_supplied_items_cost_after_reposting(self):
# Set Backflush Based On as "BOM"
set_backflush_based_on("BOM")

View File

@@ -219,7 +219,7 @@
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Reconciliaition",
"label": "Payment Reconciliation",
"link_to": "Payment Reconciliation",
"link_type": "DocType",
"show_arrow": 0,

View File

@@ -13,7 +13,7 @@
"indent": 0,
"keep_closed": 0,
"label": "Sales Tax Template",
"link_to": "Item Tax Template",
"link_to": "Sales Taxes and Charges Template",
"link_type": "DocType",
"navigate_to_tab": "",
"show_arrow": 0,
@@ -148,7 +148,7 @@
"type": "Link"
}
],
"modified": "2026-01-10 00:06:13.005238",
"modified": "2026-02-01 00:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Taxes",