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. description: Share exact version number of Frappe and ERPNext you are using.
placeholder: | placeholder: |
Frappe version - Frappe version -
ERPNext Verion - ERPNext version -
validations: validations:
required: true required: true

View File

@@ -33,6 +33,17 @@
}, },
"account_number": "1151.000" "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" "account_number": "1150.000"
}, },
"Kas": { "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", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Company Account", "label": "Company Account",
"mandatory_depends_on": "is_company_account",
"options": "Account" "options": "Account"
}, },
{ {
@@ -98,6 +99,7 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Company", "label": "Company",
"mandatory_depends_on": "is_company_account",
"options": "Company" "options": "Company"
}, },
{ {
@@ -252,7 +254,7 @@
"link_fieldname": "default_bank_account" "link_fieldname": "default_bank_account"
} }
], ],
"modified": "2025-08-29 12:32:01.081687", "modified": "2026-01-20 00:46:16.633364",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Bank Account", "name": "Bank Account",

View File

@@ -52,31 +52,35 @@ class BankAccount(Document):
delete_contact_and_address("Bank Account", self.name) delete_contact_and_address("Bank Account", self.name)
def validate(self): def validate(self):
self.validate_company() self.validate_is_company_account()
self.validate_account()
self.update_default_bank_account() self.update_default_bank_account()
def validate_account(self): def validate_is_company_account(self):
if self.account: if self.is_company_account:
if accounts := frappe.db.get_all( if not self.company:
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1 frappe.throw(_("Company is mandatory for company account"))
):
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_company(self): if not self.account:
if self.is_company_account and not self.company: frappe.throw(_("Company Account is mandatory"))
frappe.throw(_("Company is manadatory for company account"))
self.validate_account()
@deprecated @deprecated
def validate_iban(self): def validate_iban(self):
"""Kept for backward compatibility, will be removed in v16.""" """Kept for backward compatibility, will be removed in v16."""
validate_iban(self.iban, throw=True) 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): def update_default_bank_account(self):
if self.is_default and not self.disabled: if self.is_default and not self.disabled:
frappe.db.set_value( frappe.db.set_value(

View File

@@ -7,7 +7,7 @@ frappe.ui.form.on("Mode of Payment", {
let d = locals[cdt][cdn]; let d = locals[cdt][cdn];
return { return {
filters: [ filters: [
["Account", "account_type", "in", "Bank, Cash, Receivable"], ["Account", "account_type", "in", ["Bank", "Cash", "Receivable"]],
["Account", "is_group", "=", 0], ["Account", "is_group", "=", 0],
["Account", "company", "=", d.company], ["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": if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
self.assertEqual(batch.qty, 5) 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): def test_pos_batch_item_qty_validation(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
BatchNegativeStockError, BatchNegativeStockError,

View File

@@ -697,6 +697,7 @@ def get_sales_invoice_item(return_against_pos_invoice, pos_invoice_item):
& (SalesInvoice.is_return == 0) & (SalesInvoice.is_return == 0)
& (SalesInvoiceItem.pos_invoice == return_against_pos_invoice) & (SalesInvoiceItem.pos_invoice == return_against_pos_invoice)
& (SalesInvoiceItem.pos_invoice_item == pos_invoice_item) & (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: for x in allocations:
pr.append("allocation", x) pr.append("allocation", x)
skip_ref_details_update_for_pe = check_multi_currency(pr)
# reconcile # 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 # If Payment Entry, update details only for newly linked references
# This is for performance # 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") 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() @frappe.whitelist()
def is_any_doc_running(for_filter: str | dict | None = None) -> str | None: def is_any_doc_running(for_filter: str | dict | None = None) -> str | None:
running_doc = None running_doc = None

View File

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

View File

@@ -17,7 +17,7 @@
</div> </div>
<div class="col-xs-6"> <div class="col-xs-6">
<table> <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> </table>
</div> </div>
</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): 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 = [], [], [] income_data, expense_data, net_profit = [], [], []
for p in columns[2:]: for p in columns[4:]:
if income: if income:
income_data.append(income[-2].get(p.get("fieldname"))) income_data.append(income[-2].get(p.get("fieldname")))
if expense: if expense:

View File

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

View File

@@ -83,7 +83,7 @@ def validate_filters(filters):
def get_data(filters): def get_data(filters):
accounts = frappe.db.sql( 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""", from `tabAccount` where company=%s order by lft""",
filters.company, filters.company,
@@ -393,6 +393,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
"from_date": filters.from_date, "from_date": filters.from_date,
"to_date": filters.to_date, "to_date": filters.to_date,
"currency": company_currency, "currency": company_currency,
"is_group_account": d.is_group,
"account_name": ( "account_name": (
f"{d.account_number} - {d.account_name}" if d.account_number else d.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) data.append(row)
total_row = calculate_total_row(accounts, company_currency) total_row = calculate_total_row(accounts, company_currency)
if not filters.get("show_group_accounts"):
data = hide_group_accounts(data)
data.extend([{}, total_row]) data.extend([{}, total_row])
return data return data
@@ -488,3 +493,12 @@ def prepare_opening_closing(row):
row[valid_col] = 0.0 row[valid_col] = 0.0
else: else:
row[reverse_col] = 0.0 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: else:
sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None 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: if preview:
return { return {
"message": self.message_for_supplier, "message": rendered_message,
"subject": self.subject "subject": rendered_subject,
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation"),
} }
attachments = [] attachments = []
@@ -333,10 +338,8 @@ class RequestforQuotation(BuyingController):
self.send_email( self.send_email(
data, data,
sender, sender,
self.subject rendered_subject,
or frappe.get_value("Email Template", self.email_template, "subject") rendered_message,
or _("Request for Quotation"),
self.message_for_supplier,
attachments, attachments,
) )

View File

@@ -770,12 +770,34 @@ def get_filters(
if reference_voucher_detail_no: if reference_voucher_detail_no:
filters["voucher_detail_no"] = 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"): warehouses = []
filters["warehouse"] = item_row.get("warehouse") 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 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): 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 ( from erpnext.stock.doctype.serial_no.serial_no import (
get_serial_nos as get_serial_nos_from_serial_no, 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}. """Row #{0}: Selling rate for item {1} is lower than its {2}.
Selling {3} should be atleast {4}.<br><br>Alternatively, 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.""" this validation."""
).format( ).format(
idx, idx,
@@ -287,6 +287,7 @@ class SellingController(StockController):
bold(ref_rate_field), bold(ref_rate_field),
bold("net rate"), bold("net rate"),
bold(rate), bold(rate),
bold(frappe.get_meta("Selling Settings").get_label("validate_selling_price")),
get_link_to_form("Selling Settings", "Selling Settings"), get_link_to_form("Selling Settings", "Selling Settings"),
), ),
title=_("Invalid Selling Price"), title=_("Invalid Selling Price"),
@@ -298,7 +299,6 @@ class SellingController(StockController):
return return
is_internal_customer = self.get("is_internal_customer") is_internal_customer = self.get("is_internal_customer")
valuation_rate_map = {}
for item in self.items: for item in self.items:
if not item.item_code or item.is_free_item: 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") "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): 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") 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: if is_internal_customer or not is_stock_item:
continue continue
valuation_rate_map[(item.item_code, item.warehouse)] = None if item.get("incoming_rate") and item.base_net_rate < (
valuation_rate := flt(
if not valuation_rate_map: item.incoming_rate * (item.conversion_factor or 1), item.precision("base_net_rate")
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):
throw_message( throw_message(
item.idx, item.idx,
item.item_name, item.item_name,
last_valuation_rate_in_sales_uom, valuation_rate,
"valuation rate (Moving Average)", "valuation rate",
) )
def get_item_list(self): def get_item_list(self):
@@ -518,6 +486,8 @@ class SellingController(StockController):
if self.doctype not in ("Delivery Note", "Sales Invoice"): if self.doctype not in ("Delivery Note", "Sales Invoice"):
return return
from erpnext.stock.serial_batch_bundle import get_batch_nos
allow_at_arms_length_price = frappe.get_cached_value( allow_at_arms_length_price = frappe.get_cached_value(
"Stock Settings", None, "allow_internal_transfer_at_arms_length_price" "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" "Selling Settings", "set_zero_rate_for_expired_batch"
) )
old_doc = self.get_doc_before_save()
items = self.get("items") + (self.get("packed_items") or []) items = self.get("items") + (self.get("packed_items") or [])
for d in items: for d in items:
if not frappe.get_cached_value("Item", d.item_code, "is_stock_item"): 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 # 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")) 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 ( if (
not d.incoming_rate not d.incoming_rate
or self.is_internal_transfer() or self.is_internal_transfer()

View File

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

View File

@@ -465,7 +465,10 @@ class StockController(AccountsController):
if is_rejected: if is_rejected:
serial_nos = row.get("rejected_serial_no") serial_nos = row.get("rejected_serial_no")
type_of_transaction = "Inward" if not self.is_return else "Outward" 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") warehouse = row.get("rejected_warehouse")
if ( if (

View File

@@ -254,10 +254,10 @@ class SubcontractingController(StockController):
): ):
for row in frappe.get_all( for row in frappe.get_all(
f"{self.subcontract_data.order_doctype} Item", 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)}, 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): def __get_transferred_items(self):
se = frappe.qb.DocType("Stock Entry") se = frappe.qb.DocType("Stock Entry")
@@ -829,13 +829,17 @@ class SubcontractingController(StockController):
self.__set_serial_nos(item_row, rm_obj) self.__set_serial_nos(item_row, rm_obj)
def __get_qty_based_on_material_transfer(self, item_row, transfer_item): 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: if self.qty_to_be_received == item_row.qty:
return transfer_item.qty return transfer_item.qty
if self.qty_to_be_received: 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, 0)) 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 transfer_item.item_details.required_qty = transfer_item.qty
if transfer_item.serial_no or frappe.get_cached_value( if transfer_item.serial_no or frappe.get_cached_value(
@@ -880,7 +884,11 @@ class SubcontractingController(StockController):
if self.qty_to_be_received: if self.qty_to_be_received:
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 ] -= row.qty
def __set_rate_for_serial_and_batch_bundle(self): def __set_rate_for_serial_and_batch_bundle(self):

View File

@@ -701,19 +701,21 @@ class ProductionPlan(Document):
"project": self.project, "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: 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: 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: if not item_details["project"] and d.sales_order:
item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project") item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
if self.get_items_from == "Material Request": if self.get_items_from == "Material Request":
item_details.update({"qty": d.planned_qty}) 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: else:
item_details.update( item_details.update(
{ {

View File

@@ -867,7 +867,7 @@ class TestProductionPlan(FrappeTestCase):
items_data = pln.get_production_items() items_data = pln.get_production_items()
# Update qty # 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 # Create and Submit Work Order for each item in items_data
for _key, item in items_data.items(): for _key, item in items_data.items():

View File

@@ -664,7 +664,7 @@ erpnext.work_order = {
set_custom_buttons: function (frm) { set_custom_buttons: function (frm) {
var doc = frm.doc; var doc = frm.doc;
if (doc.docstatus === 1 && doc.status !== "Closed") { if (doc.docstatus === 1 && !["Closed", "Completed"].includes(doc.status)) {
frm.add_custom_button( frm.add_custom_button(
__("Close"), __("Close"),
function () { function () {
@@ -674,9 +674,6 @@ erpnext.work_order = {
}, },
__("Status") __("Status")
); );
}
if (doc.docstatus === 1 && !["Closed", "Completed"].includes(doc.status)) {
if (doc.status != "Stopped" && doc.status != "Completed") { if (doc.status != "Stopped" && doc.status != "Completed") {
frm.add_custom_button( frm.add_custom_button(
__("Stop"), __("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) 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.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.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 self.gross_margin = flt(self.total_billed_amount) - expense_amount
if self.total_billed_amount: if self.total_billed_amount:
self.per_gross_margin = (self.gross_margin / flt(self.total_billed_amount)) * 100 self.per_gross_margin = (self.gross_margin / flt(self.total_billed_amount)) * 100
else:
self.per_gross_margin = 0
def update_purchase_costing(self): def update_purchase_costing(self):
total_purchase_cost = calculate_total_purchase_cost(self.name) total_purchase_cost = calculate_total_purchase_cost(self.name)

View File

@@ -138,14 +138,14 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
frappe.run_serially([ frappe.run_serially([
() => this.set_selector_trigger_flag(data), () => 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_barcode(row, barcode),
() => this.set_warehouse(row), () => this.set_warehouse(row),
() => () =>
this.set_item(row, item_code, barcode, batch_no, serial_no).then((qty) => { this.set_item(row, item_code, barcode, batch_no, serial_no).then((qty) => {
this.show_scan_message(row.idx, !is_new_row, 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.clean_up(),
() => this.set_barcode_uom(row, uom), () => this.set_barcode_uom(row, uom),
() => this.revert_selector_flag(), () => this.revert_selector_flag(),

View File

@@ -497,6 +497,9 @@ def _set_missing_values(source, target):
if contact: if contact:
target.contact_person = contact[0].parent 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() @frappe.whitelist()

View File

@@ -446,7 +446,10 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
"Quotation", "Quotation",
source_name, source_name,
{ {
"Quotation": {"doctype": "Sales Order", "validation": {"docstatus": ["=", 1]}}, "Quotation": {
"doctype": "Sales Order",
"validation": {"docstatus": ["=", 1]},
},
"Quotation Item": { "Quotation Item": {
"doctype": "Sales Order Item", "doctype": "Sales Order Item",
"field_map": {"parent": "prevdoc_docname", "name": "quotation_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": if quotation.quotation_to == "Customer":
return frappe.get_doc("Customer", quotation.party_name) 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. # Check if a Customer already exists for the Lead or Prospect.
existing_customer = None existing_customer = None
@@ -610,25 +615,8 @@ def handle_mandatory_error(e, customer, lead_name):
def get_ordered_items(quotation: str): 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( return frappe._dict(
frappe.get_all( frappe.get_all(
"Sales Order Item", "Quotation Item", {"docstatus": 1, "parent": quotation}, ["name", "ordered_qty"], as_list=True
filters={"prevdoc_docname": quotation, "docstatus": 1},
fields=["quotation_item", "sum(qty)"],
group_by="quotation_item",
as_list=1,
) )
) )

View File

@@ -828,7 +828,7 @@ class TestQuotation(FrappeTestCase):
# item code same but description different # item code same but description different
make_item("_Test Item 2", {"is_stock_item": 1}) 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 # duplicate items
for qty in [1, 1, 2, 3]: for qty in [1, 1, 2, 3]:
@@ -842,7 +842,7 @@ class TestQuotation(FrappeTestCase):
sales_order.delivery_date = nowdate() sales_order.delivery_date = nowdate()
self.assertEqual(len(sales_order.items), 6) 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) self.assertEqual(sales_order.items[-1].qty, 5)
# Row 1: 10, Row 4: 1, Row 5: 1 # 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}", 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") test_records = frappe.get_test_records("Quotation")

View File

@@ -24,6 +24,7 @@
"uom", "uom",
"conversion_factor", "conversion_factor",
"stock_qty", "stock_qty",
"ordered_qty",
"available_quantity_section", "available_quantity_section",
"actual_qty", "actual_qty",
"column_break_ylrv", "column_break_ylrv",
@@ -694,12 +695,23 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1, "read_only": 1,
"report_hide": 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, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-08-26 20:31:47.775890", "modified": "2026-01-30 12:56:08.320190",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Quotation Item", "name": "Quotation Item",

View File

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

View File

@@ -185,6 +185,16 @@ class SalesOrder(SellingController):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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: def onload(self) -> None:
super().onload() super().onload()
@@ -419,6 +429,7 @@ class SalesOrder(SellingController):
frappe.throw(_("Row #{0}: Set Supplier for item {1}").format(d.idx, d.item_code)) frappe.throw(_("Row #{0}: Set Supplier for item {1}").format(d.idx, d.item_code))
def on_submit(self): def on_submit(self):
super().update_prevdoc_status()
self.check_credit_limit() self.check_credit_limit()
self.update_reserved_qty() self.update_reserved_qty()

View File

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

View File

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

View File

@@ -1091,7 +1091,7 @@
"no_copy": 1, "no_copy": 1,
"oldfieldname": "status", "oldfieldname": "status",
"oldfieldtype": "Select", "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_hide": 1,
"print_width": "150px", "print_width": "150px",
"read_only": 1, "read_only": 1,
@@ -1404,7 +1404,7 @@
"idx": 146, "idx": 146,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-12-02 23:55:25.415443", "modified": "2026-02-03 12:27:19.055918",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Delivery Note", "name": "Delivery Note",

View File

@@ -127,7 +127,15 @@ class DeliveryNote(SellingController):
shipping_rule: DF.Link | None shipping_rule: DF.Link | None
source: DF.Link | None source: DF.Link | None
status: DF.Literal[ 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_category: DF.Link | None
tax_id: DF.Data | None tax_id: DF.Data | None

View File

@@ -18,8 +18,10 @@ frappe.listview_settings["Delivery Note"] = {
return [__("Closed"), "green", "status,=,Closed"]; return [__("Closed"), "green", "status,=,Closed"];
} else if (doc.status === "Return Issued") { } else if (doc.status === "Return Issued") {
return [__("Return Issued"), "grey", "status,=,Return Issued"]; return [__("Return Issued"), "grey", "status,=,Return Issued"];
} else if (flt(doc.per_billed, 2) < 100) { } else if (flt(doc.per_billed) == 0) {
return [__("To Bill"), "orange", "per_billed,<,100|docstatus,=,1"]; 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) { } else if (flt(doc.per_billed, 2) === 100) {
return [__("Completed"), "green", "per_billed,=,100|docstatus,=,1"]; 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.get("items")[0].billed_amt, 400)
self.assertEqual(dn2.per_billed, 80) 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): def test_dn_billing_status_case4(self):
# SO -> SI -> DN # SO -> SI -> DN
@@ -2807,6 +2808,23 @@ class TestDeliveryNote(FrappeTestCase):
frappe.db.set_single_value("System Settings", "float_precision", original_flt_precision) 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): def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note") 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.incoming_rate, 120)
self.assertEqual(stk_ledger.stock_value_difference, 600) 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(): def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -15,6 +15,7 @@ from frappe.utils import (
cint, cint,
cstr, cstr,
flt, flt,
get_datetime,
get_link_to_form, get_link_to_form,
getdate, getdate,
now, now,
@@ -439,6 +440,8 @@ class SerialandBatchBundle(Document):
) )
def get_valuation_rate_for_return_entry(self, return_against): 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: if not self.voucher_detail_no:
return {} return {}
@@ -468,9 +471,11 @@ class SerialandBatchBundle(Document):
["Serial and Batch Bundle", "voucher_detail_no", "=", return_against_voucher_detail_no], ["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"]: if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]:
# Added to handle rejected warehouse case warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no)
filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse]) if self.warehouse in warehouses:
filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse])
bundle_data = frappe.get_all( bundle_data = frappe.get_all(
"Serial and Batch Bundle", "Serial and Batch Bundle",
@@ -1419,31 +1424,44 @@ class SerialandBatchBundle(Document):
for d in self.entries: for d in self.entries:
available_qty = batch_wise_available_qty.get(d.batch_no, 0) available_qty = batch_wise_available_qty.get(d.batch_no, 0)
if flt(available_qty, precision) < 0: if flt(available_qty, precision) < 0:
frappe.throw( self.throw_negative_batch(d.batch_no, available_qty, precision)
_(
""" def throw_negative_batch(self, batch_no, available_qty, precision):
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.""" from erpnext.stock.stock_ledger import NegativeStockError
).format(
bold(d.batch_no), frappe.throw(
bold(self.item_code), _(
bold(self.warehouse), """
bold(abs(flt(available_qty, precision))), 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(
title=_("Negative Stock Error"), 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): def get_batchwise_available_qty(self):
available_qty = self.get_available_qty_from_sabb() batchwise_entries = self.get_available_qty_from_sabb()
available_qty_from_ledger = self.get_available_qty_from_stock_ledger() batchwise_entries.extend(self.get_available_qty_from_stock_ledger())
if not available_qty_from_ledger: available_qty = frappe._dict({})
return available_qty 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(): precision = frappe.get_precision("Serial and Batch Entry", "qty")
if batch_no in available_qty: for row in batchwise_entries:
available_qty[batch_no] += qty if row.batch_no in available_qty:
available_qty[row.batch_no] += flt(row.qty)
else: 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 return available_qty
@@ -1456,7 +1474,9 @@ class SerialandBatchBundle(Document):
frappe.qb.from_(sle) frappe.qb.from_(sle)
.select( .select(
sle.batch_no, sle.batch_no,
Sum(sle.actual_qty).as_("available_qty"), sle.actual_qty.as_("qty"),
sle.posting_datetime,
sle.creation,
) )
.where( .where(
(sle.item_code == self.item_code) (sle.item_code == self.item_code)
@@ -1468,12 +1488,9 @@ class SerialandBatchBundle(Document):
& (sle.batch_no.isnotnull()) & (sle.batch_no.isnotnull())
) )
.for_update() .for_update()
.groupby(sle.batch_no)
) )
res = query.run(as_list=True) return query.run(as_dict=True)
return frappe._dict(res) if res else frappe._dict()
def get_available_qty_from_sabb(self): def get_available_qty_from_sabb(self):
batches = [d.batch_no for d in self.entries if d.batch_no] 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) .on(parent.name == child.parent)
.select( .select(
child.batch_no, child.batch_no,
Sum(child.qty).as_("total_qty"), child.qty,
CombineDatetime(parent.posting_date, parent.posting_time).as_("posting_datetime"),
parent.creation,
) )
.where( .where(
(parent.warehouse == self.warehouse) (parent.warehouse == self.warehouse)
@@ -1498,14 +1517,11 @@ class SerialandBatchBundle(Document):
& (parent.type_of_transaction.isin(["Inward", "Outward"])) & (parent.type_of_transaction.isin(["Inward", "Outward"]))
) )
.for_update() .for_update()
.groupby(child.batch_no)
) )
query = query.where(parent.voucher_type != "Pick List") query = query.where(parent.voucher_type != "Pick List")
res = query.run(as_list=True) return query.run(as_dict=True)
return frappe._dict(res) if res else frappe._dict()
def validate_voucher_no_docstatus(self): def validate_voucher_no_docstatus(self):
if self.voucher_type == "POS Invoice": if self.voucher_type == "POS Invoice":
@@ -2465,11 +2481,11 @@ def get_reserved_batches_for_pos(kwargs) -> dict:
key = (row.batch_no, row.warehouse) key = (row.batch_no, row.warehouse)
if key in pos_batches: 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: else:
pos_batches[key] = frappe._dict( pos_batches[key] = frappe._dict(
{ {
"qty": (row.qty * -1 if not row.is_return else row.qty), "qty": row.qty * -1,
"warehouse": row.warehouse, "warehouse": row.warehouse,
} }
) )

View File

@@ -226,6 +226,7 @@ class StockEntry(StockController):
self.validate_job_card_item() self.validate_job_card_item()
self.set_purpose_for_stock_entry() self.set_purpose_for_stock_entry()
self.clean_serial_nos() self.clean_serial_nos()
self.validate_repack_entry()
if not self.from_bom: if not self.from_bom:
self.fg_completed_qty = 0.0 self.fg_completed_qty = 0.0
@@ -245,6 +246,20 @@ class StockEntry(StockController):
self.validate_same_source_target_warehouse_during_material_transfer() self.validate_same_source_target_warehouse_during_material_transfer()
self.validate_raw_materials_exists() 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): def validate_raw_materials_exists(self):
if self.purpose not in ["Manufacture", "Repack", "Disassemble"]: if self.purpose not in ["Manufacture", "Repack", "Disassemble"]:
return return

View File

@@ -413,6 +413,10 @@ class TestStockEntry(FrappeTestCase):
}, },
) )
repack.set_stock_entry_type() repack.set_stock_entry_type()
for row in repack.items:
if row.t_warehouse:
row.set_basic_rate_manually = 1
repack.insert() repack.insert()
self.assertEqual(repack.items[1].is_finished_item, 1) 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: for d in items:
if (d.item_code, d.warehouse) in itemwise_batch_data: 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)): for row in itemwise_batch_data.get((d.item_code, d.warehouse)):
if ignore_empty_stock and not row.qty: if ignore_empty_stock and not row.qty:
continue continue
args = get_item_data(row, row.qty, valuation_rate) args = get_item_data(row, row.qty, row.valuation_rate)
res.append(args) res.append(args)
else: else:
stock_bal = get_stock_balance( stock_bal = get_stock_balance(
@@ -1408,6 +1404,7 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None):
"item_code": row[0], "item_code": row[0],
"warehouse": row[3], "warehouse": row[3],
"qty": row[8], "qty": row[8],
"valuation_rate": row[9],
"item_name": row[1], "item_name": row[1],
"batch_no": row[4], "batch_no": row[4],
} }

View File

@@ -198,7 +198,11 @@ class StockBalanceReport:
for field in self.inventory_dimensions: for field in self.inventory_dimensions:
qty_dict[field] = entry.get(field) 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) qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty)
else: else:
qty_diff = flt(entry.actual_qty) qty_diff = flt(entry.actual_qty)

View File

@@ -3,6 +3,7 @@
import frappe import frappe
from frappe.query_builder.functions import Coalesce, Sum
from frappe.utils import cstr, flt, now, nowdate, nowtime from frappe.utils import cstr, flt, now, nowdate, nowtime
from erpnext.controllers.stock_controller import create_repost_item_valuation_entry 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): def get_ordered_qty(item_code, warehouse):
ordered_qty = frappe.db.sql( """Return total pending ordered quantity for an item in a warehouse.
""" Includes outstanding quantities from Purchase Orders and Subcontracting Orders"""
select sum((po_item.qty - po_item.received_qty)*po_item.conversion_factor)
from `tabPurchase Order Item` po_item, `tabPurchase Order` po purchase_order_qty = get_purchase_order_qty(item_code, warehouse)
where po_item.item_code=%s and po_item.warehouse=%s subcontracting_order_qty = get_subcontracting_order_qty(item_code, warehouse)
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 return flt(purchase_order_qty) + flt(subcontracting_order_qty)
and po_item.delivered_by_supplier = 0""",
(item_code, warehouse),
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): 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.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.subcontracting_controller import SubcontractingController 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 from erpnext.stock.utils import get_bin
@@ -211,30 +211,7 @@ class SubcontractingOrder(SubcontractingController):
): ):
item_wh_list.append([item.item_code, item.warehouse]) item_wh_list.append([item.item_code, item.warehouse])
for item_code, warehouse in item_wh_list: for item_code, warehouse in item_wh_list:
update_bin_qty(item_code, warehouse, {"ordered_qty": self.get_ordered_qty(item_code, warehouse)}) update_bin_qty(item_code, warehouse, {"ordered_qty": 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
def update_reserved_qty_for_subcontracting(self, sco_item_rows=None): def update_reserved_qty_for_subcontracting(self, sco_item_rows=None):
for item in self.supplied_items: for item in self.supplied_items:

View File

@@ -618,6 +618,117 @@ class TestSubcontractingReceipt(FrappeTestCase):
for item in scr.supplied_items: for item in scr.supplied_items:
self.assertFalse(item.available_qty_for_consumption) 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): def test_supplied_items_cost_after_reposting(self):
# Set Backflush Based On as "BOM" # Set Backflush Based On as "BOM"
set_backflush_based_on("BOM") set_backflush_based_on("BOM")