mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-15 07:35:00 +00:00
Merge pull request #52348 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
16
erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py
Normal file
16
erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user