Merge pull request #52348 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
rohitwaghchaure
2026-02-03 22:47:29 +05:30
committed by GitHub
48 changed files with 736 additions and 219 deletions

View File

@@ -60,7 +60,7 @@ body:
description: Share exact version number of Frappe and ERPNext you are using.
placeholder: |
Frappe version -
ERPNext Verion -
ERPNext version -
validations:
required: true

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": {

View File

@@ -42,8 +42,4 @@ frappe.ui.form.on("Bank Account", {
});
}
},
is_company_account: function (frm) {
frm.set_df_property("account", "reqd", frm.doc.is_company_account);
},
});

View File

@@ -52,6 +52,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company Account",
"mandatory_depends_on": "is_company_account",
"options": "Account"
},
{
@@ -98,6 +99,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company",
"mandatory_depends_on": "is_company_account",
"options": "Company"
},
{
@@ -252,7 +254,7 @@
"link_fieldname": "default_bank_account"
}
],
"modified": "2025-08-29 12:32:01.081687",
"modified": "2026-01-20 00:46:16.633364",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",

View File

@@ -52,31 +52,35 @@ class BankAccount(Document):
delete_contact_and_address("Bank Account", self.name)
def validate(self):
self.validate_company()
self.validate_account()
self.validate_is_company_account()
self.update_default_bank_account()
def validate_account(self):
if self.account:
if accounts := frappe.db.get_all(
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1
):
frappe.throw(
_("'{0}' account is already used by {1}. Use another account.").format(
frappe.bold(self.account),
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
)
)
def validate_is_company_account(self):
if self.is_company_account:
if not self.company:
frappe.throw(_("Company is mandatory for company account"))
def validate_company(self):
if self.is_company_account and not self.company:
frappe.throw(_("Company is manadatory for company account"))
if not self.account:
frappe.throw(_("Company Account is mandatory"))
self.validate_account()
@deprecated
def validate_iban(self):
"""Kept for backward compatibility, will be removed in v16."""
validate_iban(self.iban, throw=True)
def validate_account(self):
if accounts := frappe.db.get_all(
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1
):
frappe.throw(
_("'{0}' account is already used by {1}. Use another account.").format(
frappe.bold(self.account),
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
)
)
def update_default_bank_account(self):
if self.is_default and not self.disabled:
frappe.db.set_value(

View File

@@ -7,7 +7,7 @@ frappe.ui.form.on("Mode of Payment", {
let d = locals[cdt][cdn];
return {
filters: [
["Account", "account_type", "in", "Bank, Cash, Receivable"],
["Account", "account_type", "in", ["Bank", "Cash", "Receivable"]],
["Account", "is_group", "=", 0],
["Account", "company", "=", d.company],
],

View File

@@ -838,6 +838,53 @@ class TestPOSInvoice(unittest.TestCase):
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

@@ -697,6 +697,7 @@ def get_sales_invoice_item(return_against_pos_invoice, pos_invoice_item):
& (SalesInvoice.is_return == 0)
& (SalesInvoiceItem.pos_invoice == return_against_pos_invoice)
& (SalesInvoiceItem.pos_invoice_item == pos_invoice_item)
& (SalesInvoice.docstatus == 1)
)
)

View File

@@ -412,8 +412,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
@@ -503,6 +504,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

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

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

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

@@ -112,6 +112,12 @@ frappe.query_reports["Trial Balance"] = {
fieldtype: "Check",
default: 1,
},
{
fieldname: "show_group_accounts",
label: __("Show Group Accounts"),
fieldtype: "Check",
default: 1,
},
],
formatter: erpnext.financial_statements.formatter,
tree: true,

View File

@@ -83,7 +83,7 @@ def validate_filters(filters):
def get_data(filters):
accounts = frappe.db.sql(
"""select name, account_number, parent_account, account_name, root_type, report_type, lft, rgt
"""select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt
from `tabAccount` where company=%s order by lft""",
filters.company,
@@ -393,6 +393,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
"from_date": filters.from_date,
"to_date": filters.to_date,
"currency": company_currency,
"is_group_account": d.is_group,
"account_name": (
f"{d.account_number} - {d.account_name}" if d.account_number else d.account_name
),
@@ -409,6 +410,10 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
data.append(row)
total_row = calculate_total_row(accounts, company_currency)
if not filters.get("show_group_accounts"):
data = hide_group_accounts(data)
data.extend([{}, total_row])
return data
@@ -488,3 +493,12 @@ def prepare_opening_closing(row):
row[valid_col] = 0.0
else:
row[reverse_col] = 0.0
def hide_group_accounts(data):
non_group_accounts_data = []
for d in data:
if not d.get("is_group_account"):
d.update(indent=0)
non_group_accounts_data.append(d)
return non_group_accounts_data

View File

@@ -304,12 +304,17 @@ class RequestforQuotation(BuyingController):
else:
sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None
rendered_message = frappe.render_template(self.message_for_supplier, doc_args)
subject_source = (
self.subject
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation")
)
rendered_subject = frappe.render_template(subject_source, doc_args)
if preview:
return {
"message": self.message_for_supplier,
"subject": self.subject
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation"),
"message": rendered_message,
"subject": rendered_subject,
}
attachments = []
@@ -333,10 +338,8 @@ class RequestforQuotation(BuyingController):
self.send_email(
data,
sender,
self.subject
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation"),
self.message_for_supplier,
rendered_subject,
rendered_message,
attachments,
)

View File

@@ -770,12 +770,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

@@ -279,7 +279,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,
@@ -287,6 +287,7 @@ class SellingController(StockController):
bold(ref_rate_field),
bold("net rate"),
bold(rate),
bold(frappe.get_meta("Selling Settings").get_label("validate_selling_price")),
get_link_to_form("Selling Settings", "Selling Settings"),
),
title=_("Invalid Selling Price"),
@@ -298,7 +299,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:
@@ -308,7 +308,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")
@@ -316,50 +318,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):
@@ -518,6 +486,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"
)
@@ -525,6 +495,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"):
@@ -554,6 +525,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

@@ -83,7 +83,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"],
@@ -341,14 +342,17 @@ class StatusUpdater(Document):
):
return
if qty_or_amount == "qty":
action_msg = _(
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
)
if args["target_dt"] != "Quotation Item":
if qty_or_amount == "qty":
action_msg = _(
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
)
else:
action_msg = _(
'To allow over billing, update "Over Billing Allowance" in Accounts Settings or the Item.'
)
else:
action_msg = _(
'To allow over billing, update "Over Billing Allowance" in Accounts Settings or the Item.'
)
action_msg = None
frappe.throw(
_(
@@ -360,8 +364,7 @@ class StatusUpdater(Document):
frappe.bold(_(self.doctype)),
frappe.bold(item.get("item_code")),
)
+ "<br><br>"
+ action_msg,
+ ("<br><br>" + action_msg if action_msg else ""),
OverAllowanceError,
title=_("Limit Crossed"),
)

View File

@@ -465,7 +465,10 @@ class StockController(AccountsController):
if is_rejected:
serial_nos = row.get("rejected_serial_no")
type_of_transaction = "Inward" if not self.is_return else "Outward"
qty = row.get("rejected_qty") * row.get("conversion_factor", 1.0)
qty = flt(
row.get("rejected_qty") * row.get("conversion_factor", 1.0),
frappe.get_precision("Serial and Batch Entry", "qty"),
)
warehouse = row.get("rejected_warehouse")
if (

View File

@@ -254,10 +254,10 @@ class SubcontractingController(StockController):
):
for row in frappe.get_all(
f"{self.subcontract_data.order_doctype} Item",
fields=["item_code", "(qty - received_qty) as qty", "parent", "name"],
fields=["item_code", "(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")
@@ -829,13 +829,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(
@@ -880,7 +884,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

@@ -701,19 +701,21 @@ class ProductionPlan(Document):
"project": self.project,
}
key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse)
key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse, d.planned_start_date)
if self.combine_items:
key = (d.item_code, d.sales_order, d.warehouse)
key = (d.item_code, d.sales_order, d.warehouse, d.planned_start_date)
if not d.sales_order:
key = (d.name, d.item_code, d.warehouse)
key = (d.name, d.item_code, d.warehouse, d.planned_start_date)
if not item_details["project"] and d.sales_order:
item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
if self.get_items_from == "Material Request":
item_details.update({"qty": d.planned_qty})
item_dict[(d.item_code, d.material_request_item, d.warehouse)] = item_details
item_dict[
(d.item_code, d.material_request_item, d.warehouse, d.planned_start_date)
] = item_details
else:
item_details.update(
{

View File

@@ -867,7 +867,7 @@ class TestProductionPlan(FrappeTestCase):
items_data = pln.get_production_items()
# Update qty
items_data[(pln.po_items[0].name, item, None)]["qty"] = qty
items_data[(pln.po_items[0].name, item, None, pln.po_items[0].planned_start_date)]["qty"] = qty
# Create and Submit Work Order for each item in items_data
for _key, item in items_data.items():

View File

@@ -664,7 +664,7 @@ erpnext.work_order = {
set_custom_buttons: function (frm) {
var doc = frm.doc;
if (doc.docstatus === 1 && doc.status !== "Closed") {
if (doc.docstatus === 1 && !["Closed", "Completed"].includes(doc.status)) {
frm.add_custom_button(
__("Close"),
function () {
@@ -674,9 +674,6 @@ erpnext.work_order = {
},
__("Status")
);
}
if (doc.docstatus === 1 && !["Closed", "Completed"].includes(doc.status)) {
if (doc.status != "Stopped" && doc.status != "Completed") {
frm.add_custom_button(
__("Stop"),

View File

@@ -428,3 +428,4 @@ execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1)
execute:frappe.db.set_single_value("Accounts Settings", "show_account_balance", 1)
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
erpnext.patches.v16_0.set_ordered_qty_in_quotation_item

View File

@@ -0,0 +1,16 @@
import frappe
def execute():
data = frappe.get_all(
"Sales Order Item",
filters={"quotation_item": ["is", "set"], "docstatus": 1},
fields=["quotation_item", "sum(stock_qty) as ordered_qty"],
group_by="quotation_item",
)
if data:
frappe.db.auto_commit_on_many_writes = 1
frappe.db.bulk_update(
"Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data}
)
frappe.db.auto_commit_on_many_writes = 0

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

@@ -138,14 +138,14 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
frappe.run_serially([
() => this.set_selector_trigger_flag(data),
() => this.set_serial_no(row, serial_no),
() => this.set_batch_no(row, batch_no),
() => this.set_barcode(row, barcode),
() => this.set_warehouse(row),
() =>
this.set_item(row, item_code, barcode, batch_no, serial_no).then((qty) => {
this.show_scan_message(row.idx, !is_new_row, qty);
}),
() => this.set_serial_no(row, serial_no),
() => this.set_batch_no(row, batch_no),
() => this.clean_up(),
() => this.set_barcode_uom(row, uom),
() => this.revert_selector_flag(),

View File

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

@@ -446,7 +446,10 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
"Quotation",
source_name,
{
"Quotation": {"doctype": "Sales Order", "validation": {"docstatus": ["=", 1]}},
"Quotation": {
"doctype": "Sales Order",
"validation": {"docstatus": ["=", 1]},
},
"Quotation Item": {
"doctype": "Sales Order Item",
"field_map": {"parent": "prevdoc_docname", "name": "quotation_item"},
@@ -549,6 +552,8 @@ def _make_customer(source_name, ignore_permissions=False):
if quotation.quotation_to == "Customer":
return frappe.get_doc("Customer", quotation.party_name)
elif quotation.quotation_to == "CRM Deal":
return frappe.get_doc("Customer", {"crm_deal": quotation.party_name})
# Check if a Customer already exists for the Lead or Prospect.
existing_customer = None
@@ -610,25 +615,8 @@ def handle_mandatory_error(e, customer, lead_name):
def get_ordered_items(quotation: str):
"""
Returns a dict of ordered items with their total qty based on quotation row name.
In `Sales Order Item`, `quotation_item` is the row name of `Quotation Item`.
Example:
```
{
"refsdjhd2": 10,
"ygdhdshrt": 5,
}
```
"""
return frappe._dict(
frappe.get_all(
"Sales Order Item",
filters={"prevdoc_docname": quotation, "docstatus": 1},
fields=["quotation_item", "sum(qty)"],
group_by="quotation_item",
as_list=1,
"Quotation Item", {"docstatus": 1, "parent": quotation}, ["name", "ordered_qty"], as_list=True
)
)

View File

@@ -828,7 +828,7 @@ class TestQuotation(FrappeTestCase):
# item code same but description different
make_item("_Test Item 2", {"is_stock_item": 1})
quotation = make_quotation(qty=1, rate=100, do_not_submit=1)
quotation = make_quotation(qty=10, rate=100, do_not_submit=1)
# duplicate items
for qty in [1, 1, 2, 3]:
@@ -842,7 +842,7 @@ class TestQuotation(FrappeTestCase):
sales_order.delivery_date = nowdate()
self.assertEqual(len(sales_order.items), 6)
self.assertEqual(sales_order.items[0].qty, 1)
self.assertEqual(sales_order.items[0].qty, 10)
self.assertEqual(sales_order.items[-1].qty, 5)
# Row 1: 10, Row 4: 1, Row 5: 1
@@ -885,6 +885,18 @@ class TestQuotation(FrappeTestCase):
f"Expected conversion rate {expected_rate}, got {quotation.conversion_rate}",
)
def test_over_order_limit(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order
quotation = make_quotation(qty=5)
so1 = make_sales_order(quotation.name)
so2 = make_sales_order(quotation.name)
so1.delivery_date = nowdate()
so2.delivery_date = nowdate()
so1.submit()
self.assertRaises(frappe.ValidationError, so2.submit)
test_records = frappe.get_test_records("Quotation")

View File

@@ -24,6 +24,7 @@
"uom",
"conversion_factor",
"stock_qty",
"ordered_qty",
"available_quantity_section",
"actual_qty",
"column_break_ylrv",
@@ -694,12 +695,23 @@
"print_hide": 1,
"read_only": 1,
"report_hide": 1
},
{
"default": "0",
"fieldname": "ordered_qty",
"fieldtype": "Float",
"hidden": 1,
"label": "Ordered Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1,
"reqd": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-08-26 20:31:47.775890",
"modified": "2026-01-30 12:56:08.320190",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation Item",

View File

@@ -48,6 +48,7 @@ class QuotationItem(Document):
margin_type: DF.Literal["", "Percentage", "Amount"]
net_amount: DF.Currency
net_rate: DF.Currency
ordered_qty: DF.Float
page_break: DF.Check
parent: DF.Data
parentfield: DF.Data

View File

@@ -185,6 +185,16 @@ class SalesOrder(SellingController):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.status_updater = [
{
"source_dt": "Sales Order Item",
"target_dt": "Quotation Item",
"join_field": "quotation_item",
"target_field": "ordered_qty",
"target_ref_field": "stock_qty",
"source_field": "stock_qty",
}
]
def onload(self) -> None:
super().onload()
@@ -419,6 +429,7 @@ class SalesOrder(SellingController):
frappe.throw(_("Row #{0}: Set Supplier for item {1}").format(d.idx, d.item_code))
def on_submit(self):
super().update_prevdoc_status()
self.check_credit_limit()
self.update_reserved_qty()

View File

@@ -57,6 +57,7 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
def tearDown(self):
frappe.set_user("Administrator")
@change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
def test_sales_order_with_negative_rate(self):
"""
Test if negative rate is allowed in Sales Order via doc submission and update items

View File

@@ -4844,17 +4844,17 @@
"Switzerland": {
"Switzerland normal VAT": {
"account_name": "VAT 7.7%",
"tax_rate": 7.70,
"account_name": "VAT 8.1%",
"tax_rate": 8.10,
"default": 1
},
"Switzerland reduced VAT": {
"account_name": "VAT 2.5%",
"tax_rate": 2.50
"account_name": "VAT 2.6%",
"tax_rate": 2.60
},
"Switzerland lodging VAT": {
"account_name": "VAT 3.7%",
"tax_rate": 3.70
"account_name": "VAT 3.8%",
"tax_rate": 3.80
}
},

View File

@@ -1091,7 +1091,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,
@@ -1404,7 +1404,7 @@
"idx": 146,
"is_submittable": 1,
"links": [],
"modified": "2025-12-02 23:55:25.415443",
"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_rule: DF.Link | None
source: 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

@@ -1093,7 +1093,8 @@ class TestDeliveryNote(FrappeTestCase):
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
@@ -2807,6 +2808,23 @@ class TestDeliveryNote(FrappeTestCase):
frappe.db.set_single_value("System Settings", "float_precision", original_flt_precision)
@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

@@ -4794,6 +4794,128 @@ class TestPurchaseReceipt(FrappeTestCase):
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

@@ -15,6 +15,7 @@ from frappe.utils import (
cint,
cstr,
flt,
get_datetime,
get_link_to_form,
getdate,
now,
@@ -439,6 +440,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 {}
@@ -468,9 +471,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",
@@ -1419,31 +1424,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
@@ -1456,7 +1474,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)
@@ -1468,12 +1488,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]
@@ -1487,7 +1504,9 @@ class SerialandBatchBundle(Document):
.on(parent.name == child.parent)
.select(
child.batch_no,
Sum(child.qty).as_("total_qty"),
child.qty,
CombineDatetime(parent.posting_date, parent.posting_time).as_("posting_datetime"),
parent.creation,
)
.where(
(parent.warehouse == self.warehouse)
@@ -1498,14 +1517,11 @@ class SerialandBatchBundle(Document):
& (parent.type_of_transaction.isin(["Inward", "Outward"]))
)
.for_update()
.groupby(child.batch_no)
)
query = query.where(parent.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":
@@ -2465,11 +2481,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

@@ -226,6 +226,7 @@ class StockEntry(StockController):
self.validate_job_card_item()
self.set_purpose_for_stock_entry()
self.clean_serial_nos()
self.validate_repack_entry()
if not self.from_bom:
self.fg_completed_qty = 0.0
@@ -245,6 +246,20 @@ class StockEntry(StockController):
self.validate_same_source_target_warehouse_during_material_transfer()
self.validate_raw_materials_exists()
def validate_repack_entry(self):
if self.purpose != "Repack":
return
fg_items = {row.item_code: row for row in self.items if row.is_finished_item}
if len(fg_items) > 1 and not all(row.set_basic_rate_manually for row in fg_items.values()):
frappe.throw(
_(
"When there are multiple finished goods ({0}) in a Repack stock entry, the basic rate for all finished goods must be set manually. To set rate manually, enable the checkbox 'Set Basic Rate Manually' in the respective finished good row."
).format(", ".join(fg_items)),
title=_("Set Basic Rate Manually"),
)
def validate_raw_materials_exists(self):
if self.purpose not in ["Manufacture", "Repack", "Disassemble"]:
return

View File

@@ -413,6 +413,10 @@ class TestStockEntry(FrappeTestCase):
},
)
repack.set_stock_entry_type()
for row in repack.items:
if row.t_warehouse:
row.set_basic_rate_manually = 1
repack.insert()
self.assertEqual(repack.items[1].is_finished_item, 1)

View File

@@ -1266,15 +1266,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(
@@ -1408,6 +1404,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

@@ -198,7 +198,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

@@ -8,7 +8,7 @@ from frappe.utils import flt
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.subcontracting_controller import SubcontractingController
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
@@ -211,30 +211,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

@@ -618,6 +618,117 @@ class TestSubcontractingReceipt(FrappeTestCase):
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")