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

chore: release v15
This commit is contained in:
Diptanil Saha
2025-11-04 18:01:18 +05:30
committed by GitHub
23 changed files with 555 additions and 40 deletions

View File

@@ -110,6 +110,7 @@ class Account(NestedSet):
self.validate_parent_child_account_type()
self.validate_root_details()
self.validate_account_number()
self.validate_disabled()
self.validate_group_or_ledger()
self.set_root_and_report_type()
self.validate_mandatory()
@@ -254,6 +255,14 @@ class Account(NestedSet):
self.create_account_for_child_company(parent_acc_name_map, descendants, parent_acc_name)
def validate_disabled(self):
doc_before_save = self.get_doc_before_save()
if not doc_before_save or cint(doc_before_save.disabled) == cint(self.disabled):
return
if cint(self.disabled):
self.validate_default_accounts_in_company()
def validate_group_or_ledger(self):
doc_before_save = self.get_doc_before_save()
if not doc_before_save or cint(doc_before_save.is_group) == cint(self.is_group):
@@ -264,9 +273,32 @@ class Account(NestedSet):
elif cint(self.is_group):
if self.account_type and not self.flags.exclude_account_type_check:
throw(_("Cannot covert to Group because Account Type is selected."))
self.validate_default_accounts_in_company()
elif self.check_if_child_exists():
throw(_("Account with child nodes cannot be set as ledger"))
def validate_default_accounts_in_company(self):
default_account_fields = get_company_default_account_fields()
company_default_accounts = frappe.db.get_value(
"Company", self.company, list(default_account_fields.keys()), as_dict=1
)
msg = _("Account {0} cannot be disabled as it is already set as {1} for {2}.")
if not self.disabled:
msg = _("Account {0} cannot be converted to Group as it is already set as {1} for {2}.")
for d in default_account_fields:
if company_default_accounts.get(d) == self.name:
throw(
msg.format(
frappe.bold(self.name),
frappe.bold(default_account_fields.get(d)),
frappe.bold(self.company),
)
)
def validate_frozen_accounts_modifier(self):
doc_before_save = self.get_doc_before_save()
if not doc_before_save or doc_before_save.freeze_account == self.freeze_account:
@@ -627,3 +659,27 @@ def _ensure_idle_system():
).format(pretty_date(last_gl_update)),
title=_("System In Use"),
)
def get_company_default_account_fields():
return {
"default_bank_account": "Default Bank Account",
"default_cash_account": "Default Cash Account",
"default_receivable_account": "Default Receivable Account",
"default_payable_account": "Default Payable Account",
"default_expense_account": "Default Expense Account",
"default_income_account": "Default Income Account",
"stock_received_but_not_billed": "Stock Received But Not Billed Account",
"stock_adjustment_account": "Stock Adjustment Account",
"write_off_account": "Write Off Account",
"default_discount_account": "Default Payment Discount Account",
"unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
"exchange_gain_loss_account": "Exchange Gain / Loss Account",
"unrealized_exchange_gain_loss_account": "Unrealized Exchange Gain / Loss Account",
"round_off_account": "Round Off Account",
"default_deferred_revenue_account": "Default Deferred Revenue Account",
"default_deferred_expense_account": "Default Deferred Expense Account",
"accumulated_depreciation_account": "Accumulated Depreciation Account",
"depreciation_expense_account": "Depreciation Expense Account",
"disposal_account": "Gain/Loss Account on Asset Disposal",
}

View File

@@ -9,13 +9,6 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.set_query("bank", function () {
return {
filters: {
company: frm.doc.company,
},
};
});
frm.set_query("bank_account", function () {
return {
filters: {

View File

@@ -664,7 +664,16 @@ class ReceivablePayableReport:
invoiced = d.base_payment_amount
paid_amount = d.base_paid_amount
if company_currency == d.party_account_currency or self.filters.get("in_party_currency"):
in_party_currency = self.filters.get("in_party_currency")
# company, billing, and party account currencies are the same
if company_currency == d.currency and company_currency == d.party_account_currency:
in_party_currency = False
# When filtered by party currency and the billing currency not matches the party account currency
if in_party_currency and d.currency != d.party_account_currency:
in_party_currency = False
if in_party_currency:
invoiced = d.payment_amount
paid_amount = d.paid_amount

View File

@@ -199,6 +199,81 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
row = report[1]
self.assertTrue(len(row) == 0)
@change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1},
)
def test_allow_multi_currency_invoices_against_single_party_account(self):
filters = {
"company": self.company,
"based_on_payment_terms": 1,
"report_date": today(),
"range": "30, 60, 90, 120",
"show_remarks": True,
"in_party_currency": 1,
}
# CASE 1: Company currency and party account currency are the same
si = self.create_sales_invoice(qty=1, no_payment_schedule=True, do_not_submit=True)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
filters.update(
{
"party_type": "Customer",
"party": [self.customer],
}
)
report = execute(filters)
row = report[1][0]
expected_data = [8000, 8000, "No Remarks"] # Data in company currency
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
# CASE 2: Transaction currency and party account currency are the same
self.create_customer(
"USD Customer", currency="USD", default_account=self.debtors_usd, company=self.company
)
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
currency="USD",
conversion_rate=80,
price_list_rate=100,
do_not_save=1,
)
si.save().submit()
filters.update(
{
"party_type": "Customer",
"party": [self.customer],
}
)
report = execute(filters)
row = report[1][0]
expected_data = [100, 100, "No Remarks"] # Data in Part Account Currency
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
# View in Company currency
filters.pop("in_party_currency")
report = execute(filters)
row = report[1][0]
expected_data = [8000, 8000, "No Remarks"] # Data in Company Currency
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
def test_accounts_receivable_with_partial_payment(self):
filters = {
"company": self.company,

View File

@@ -85,6 +85,12 @@ frappe.query_reports["Gross Profit"] = {
});
},
},
{
fieldname: "include_returned_invoices",
label: __("Include Returned Invoices (Stand-alone)"),
fieldtype: "Check",
default: 1,
},
],
tree: true,
name_field: "parent",

View File

@@ -859,7 +859,10 @@ class GrossProfitGenerator:
if self.filters.to_date:
conditions += " and posting_date <= %(to_date)s"
conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
if self.filters.include_returned_invoices:
conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
else:
conditions += " and is_return = 0"
if self.filters.item_group:
conditions += f" and {get_item_group_condition(self.filters.item_group)}"

View File

@@ -442,7 +442,11 @@ class TestGrossProfit(FrappeTestCase):
sinv = sinv.save().submit()
filters = frappe._dict(
company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice"
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Invoice",
include_returned_invoices=1,
)
columns, data = execute(filters=filters)

View File

@@ -5,7 +5,9 @@ from erpnext.stock.doctype.item.test_item import create_item
class AccountsTestMixin:
def create_customer(self, customer_name="_Test Customer", currency=None):
def create_customer(
self, customer_name="_Test Customer", currency=None, default_account=None, company=None
):
if not frappe.db.exists("Customer", customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
@@ -13,9 +15,28 @@ class AccountsTestMixin:
if currency:
customer.default_currency = currency
if company and default_account:
customer.append(
"accounts",
{
"company": company,
"account": default_account,
},
)
customer.save()
self.customer = customer.name
else:
if company and default_account:
customer = frappe.get_doc("Customer", customer_name)
customer.accounts = []
customer.append(
"accounts",
{
"company": company,
"account": default_account,
},
)
customer.save()
self.customer = customer_name
def create_supplier(self, supplier_name="_Test Supplier", currency=None):

View File

@@ -939,7 +939,7 @@ def make_post_gl_entry():
assets = frappe.db.sql_list(
""" select name from `tabAsset`
where asset_category = %s and ifnull(booked_fixed_asset, 0) = 0
and available_for_use_date = %s""",
and available_for_use_date = %s and docstatus = 1""",
(asset_category.name, nowdate()),
)

View File

@@ -2026,7 +2026,7 @@ class AccountsController(TransactionBase):
discount_amount * self.get("conversion_rate"),
item.precision("discount_amount"),
),
dr_or_cr + "_in_account_currency": flt(
dr_or_cr + "_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
@@ -2047,7 +2047,7 @@ class AccountsController(TransactionBase):
discount_amount * self.get("conversion_rate"),
item.precision("discount_amount"),
),
rev_dr_cr + "_in_account_currency": flt(
rev_dr_cr + "_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
@@ -3661,8 +3661,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item"
return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row)
def is_allowed_zero_qty():
if parent_doctype == "Sales Order":
return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False
elif parent_doctype == "Purchase Order":
return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False
return False
def validate_quantity(child_item, new_data):
if not flt(new_data.get("qty")):
if not flt(new_data.get("qty")) and not is_allowed_zero_qty():
frappe.throw(
_("Row #{0}: Quantity for Item {1} cannot be zero.").format(
new_data.get("idx"), frappe.bold(new_data.get("item_code"))
@@ -3798,6 +3805,11 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
conv_fac_precision = child_item.precision("conversion_factor") or 2
qty_precision = child_item.precision("qty") or 2
prev_rate, new_rate = flt(child_item.get("rate")), flt(d.get("rate"))
rate_unchanged = prev_rate == new_rate
if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty():
frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price"))))
# Amount cannot be lesser than billed amount, except for negative amounts
row_rate = flt(d.get("rate"), rate_precision)
amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(

View File

@@ -480,6 +480,13 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
target_doc.subcontracting_order_item = source_doc.subcontracting_order_item
target_doc.rejected_warehouse = source_doc.rejected_warehouse
target_doc.subcontracting_receipt_item = source_doc.name
if return_against_rejected_qty:
target_doc.qty = -1 * flt(source_doc.rejected_qty - (returned_qty_map.get("qty") or 0))
target_doc.rejected_qty = 0.0
target_doc.rejected_warehouse = ""
target_doc.warehouse = source_doc.rejected_warehouse
target_doc.received_qty = target_doc.qty
target_doc.return_qty_from_rejected_warehouse = 1
else:
target_doc.purchase_order = source_doc.purchase_order
target_doc.purchase_order_item = source_doc.purchase_order_item

View File

@@ -279,6 +279,7 @@
"oldfieldtype": "Section Break"
},
{
"allow_bulk_edit": 1,
"fieldname": "items",
"fieldtype": "Table",
"label": "Items",
@@ -590,6 +591,7 @@
},
{
"default": "0",
"depends_on": "eval:doc.track_semi_finished_goods === 0",
"fieldname": "fg_based_operating_cost",
"fieldtype": "Check",
"label": "Finished Goods based Operating Cost"
@@ -638,7 +640,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2025-06-16 16:13:22.497695",
"modified": "2025-10-29 17:43:12.966753",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",

View File

@@ -158,6 +158,7 @@ class BOM(WebsiteGenerator):
show_operations: DF.Check
thumbnail: DF.Data | None
total_cost: DF.Currency
track_semi_finished_goods: DF.Check
transfer_material_against: DF.Literal["", "Work Order", "Job Card"]
uom: DF.Link | None
web_long_description: DF.TextEditor | None

View File

@@ -408,7 +408,7 @@ frappe.ui.form.on("Job Card", {
function updateStopwatch(increment) {
var hours = Math.floor(increment / 3600);
var minutes = Math.floor((increment - hours * 3600) / 60);
var seconds = increment - hours * 3600 - minutes * 60;
var seconds = Math.floor(increment - hours * 3600 - minutes * 60);
$(section)
.find(".hours")
@@ -431,7 +431,7 @@ frappe.ui.form.on("Job Card", {
frm.dashboard.refresh();
const timer = `
<div class="stopwatch" style="font-weight:bold;margin:0px 13px 0px 2px;
color:#545454;font-size:18px;display:inline-block;vertical-align:text-bottom;>
color:#545454;font-size:18px;display:inline-block;vertical-align:text-bottom;">
<span class="hours">00</span>
<span class="colon">:</span>
<span class="minutes">00</span>
@@ -441,20 +441,34 @@ frappe.ui.form.on("Job Card", {
var section = frm.toolbar.page.add_inner_message(timer);
let currentIncrement = frm.doc.current_time || 0;
let currentIncrement = frm.events.get_current_time(frm);
if (frm.doc.started_time || frm.doc.current_time) {
if (frm.doc.status == "On Hold") {
updateStopwatch(currentIncrement);
} else {
currentIncrement += moment(frappe.datetime.now_datetime()).diff(
moment(frm.doc.started_time),
"seconds"
);
initialiseTimer();
}
}
},
get_current_time(frm) {
let current_time = 0;
frm.doc.time_logs.forEach((d) => {
if (d.to_time) {
if (d.time_in_mins) {
current_time += flt(d.time_in_mins, 2) * 60;
} else {
current_time += get_seconds_diff(d.to_time, d.from_time);
}
} else {
current_time += get_seconds_diff(frappe.datetime.now_datetime(), d.from_time);
}
});
return current_time;
},
hide_timer: function (frm) {
frm.toolbar.page.inner_toolbar.find(".stopwatch").remove();
},
@@ -519,3 +533,7 @@ frappe.ui.form.on("Job Card Time Log", {
frm.set_value("started_time", "");
},
});
function get_seconds_diff(d1, d2) {
return moment(d1).diff(d2, "seconds");
}

View File

@@ -17,17 +17,7 @@ erpnext.buying = {
this.setup_queries(doc, cdt, cdn);
super.onload();
if (["Purchase Order", "Purchase Receipt", "Purchase Invoice"].includes(this.frm.doctype)) {
this.frm.set_query("supplier", function () {
return {
filters: {
is_transporter: 0,
},
};
});
}
this.frm.set_query("shipping_rule", function () {
this.frm.set_query('shipping_rule', function() {
return {
filters: {
"shipping_rule_type": "Buying"

View File

@@ -181,11 +181,35 @@ class Company(NestedSet):
["Stock Received But Not Billed Account", "stock_received_but_not_billed"],
["Stock Adjustment Account", "stock_adjustment_account"],
["Expense Included In Valuation Account", "expenses_included_in_valuation"],
["Write Off Account", "write_off_account"],
["Default Payment Discount Account", "default_discount_account"],
["Unrealized Profit / Loss Account", "unrealized_profit_loss_account"],
["Exchange Gain / Loss Account", "exchange_gain_loss_account"],
["Unrealized Exchange Gain / Loss Account", "unrealized_exchange_gain_loss_account"],
["Round Off Account", "round_off_account"],
["Default Deferred Revenue Account", "default_deferred_revenue_account"],
["Default Deferred Expense Account", "default_deferred_expense_account"],
["Accumulated Depreciation Account", "accumulated_depreciation_account"],
["Depreciation Expense Account", "depreciation_expense_account"],
["Gain/Loss Account on Asset Disposal", "disposal_account"],
]
for account in accounts:
if self.get(account[1]):
for_company = frappe.db.get_value("Account", self.get(account[1]), "company")
for_company, is_group, disabled = frappe.db.get_value(
"Account", self.get(account[1]), ["company", "is_group", "disabled"]
)
if disabled:
frappe.throw(_("Account {0} is disabled.").format(frappe.bold(self.get(account[1]))))
if is_group:
frappe.throw(
_("{0}: {1} is a group account.").format(
frappe.bold(account[0]), frappe.bold(self.get(account[1]))
)
)
if for_company != self.name:
frappe.throw(
_("Account {0} does not belong to company: {1}").format(

View File

@@ -4287,6 +4287,67 @@ class TestPurchaseReceipt(FrappeTestCase):
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
@change_settings(
"Buying Settings",
{"bill_for_rejected_quantity_in_purchase_invoice": 1, "set_valuation_rate_for_rejected_materials": 1},
)
def test_valuation_rate_for_rejected_materials_with_serial_no(self):
item = make_item(
"Test Serial Item with Rej Material Valuation",
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "SNU-TSIRMV-.#####"},
)
company = "_Test Company with perpetual inventory"
warehouse = create_warehouse(
"_Test In-ward Warehouse",
company="_Test Company with perpetual inventory",
)
rej_warehouse = create_warehouse(
"_Test Warehouse - Rejected Material",
company="_Test Company with perpetual inventory",
)
pr = make_purchase_receipt(
item_code=item.name,
qty=10,
rate=100,
company=company,
warehouse=warehouse,
rejected_qty=5,
rejected_warehouse=rej_warehouse,
)
stock_received_but_not_billed_account = frappe.get_value(
"Company",
company,
"stock_received_but_not_billed",
)
rejected_item_cost = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"warehouse": rej_warehouse,
},
"stock_value_difference",
)
self.assertEqual(rejected_item_cost, 500)
srbnb_cost = frappe.db.get_value(
"GL Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"account": stock_received_but_not_billed_account,
},
"credit",
)
self.assertEqual(srbnb_cost, 1500)
def test_valuation_rate_for_rejected_materials_withoout_accepted_materials(self):
item = make_item("Test Item with Rej Material Valuation WO Accepted", {"is_stock_item": 1})
company = "_Test Company with perpetual inventory"

View File

@@ -667,8 +667,12 @@ class SerialandBatchBundle(Document):
if batches and valuation_method == "FIFO":
stock_queue = parse_json(prev_sle.stock_queue)
set_valuation_rate_for_rejected_materials = frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
for d in self.entries:
if self.is_rejected:
if self.is_rejected and not set_valuation_rate_for_rejected_materials:
rate = 0.0
elif (d.incoming_rate == rate) and not stock_queue and d.qty and d.stock_value_difference:
continue

View File

@@ -241,6 +241,8 @@ class StockEntry(StockController):
self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
self.reset_default_field_value("to_warehouse", "items", "t_warehouse")
self.validate_same_source_target_warehouse_during_material_transfer()
def on_submit(self):
self.validate_closed_subcontracting_order()
self.make_bundle_using_old_serial_batch_fields()
@@ -796,6 +798,53 @@ class StockEntry(StockController):
title=_("Missing Item"),
)
def validate_same_source_target_warehouse_during_material_transfer(self):
"""
Validate Material Transfer entries where source and target warehouses are identical.
For Material Transfer purpose, if an item has the same source and target warehouse,
require that at least one inventory dimension (if configured) differs between source
and target to ensure a meaningful transfer is occurring.
Raises:
frappe.ValidationError: If warehouses are same and no inventory dimensions differ
"""
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
inventory_dimensions = get_inventory_dimensions()
if self.purpose == "Material Transfer":
for item in self.items:
if cstr(item.s_warehouse) == cstr(item.t_warehouse):
if not inventory_dimensions:
frappe.throw(
_(
"Row #{0}: Source and Target Warehouse cannot be the same for Material Transfer"
).format(item.idx),
title=_("Invalid Source and Target Warehouse"),
)
else:
difference_found = False
for dimension in inventory_dimensions:
fieldname = (
dimension.source_fieldname
if dimension.source_fieldname.startswith("to_")
else f"to_{dimension.source_fieldname}"
)
if (
item.get(dimension.source_fieldname)
and item.get(fieldname)
and item.get(dimension.source_fieldname) != item.get(fieldname)
):
difference_found = True
break
if not difference_found:
frappe.throw(
_(
"Row #{0}: Source, Target Warehouse and Inventory Dimensions cannot be the exact same for Material Transfer"
).format(item.idx),
title=_("Invalid Source and Target Warehouse"),
)
def get_matched_items(self, item_code):
for row in self.items:
if row.item_code == item_code or row.original_item == item_code:

View File

@@ -1914,7 +1914,7 @@ def get_valuation_rate(
)
last_valuation_rate = query.run()
if last_valuation_rate:
if last_valuation_rate and last_valuation_rate[0][0] is not None:
return flt(last_valuation_rate[0][0])
# Get moving average rate of a specific batch number

View File

@@ -82,10 +82,53 @@ frappe.ui.form.on("Subcontracting Receipt", {
frm.add_custom_button(
__("Subcontract Return"),
() => {
frappe.model.open_mapped_doc({
method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return",
frm: frm,
const make_standard_return = () => {
frappe.model.open_mapped_doc({
method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return",
frm: frm,
});
};
let has_rejected_items = frm.doc.items.filter((item) => {
if (item.rejected_qty > 0) {
return true;
}
});
if (has_rejected_items && has_rejected_items.length > 0) {
frappe.prompt(
[
{
label: __("Return Qty from Rejected Warehouse"),
fieldtype: "Check",
fieldname: "return_for_rejected_warehouse",
default: 1,
},
],
function (values) {
if (values.return_for_rejected_warehouse) {
frappe.call({
method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return_against_rejected_warehouse",
args: {
source_name: frm.doc.name,
},
callback: function (r) {
if (r.message) {
frappe.model.sync(r.message);
frappe.set_route("Form", r.message.doctype, r.message.name);
}
},
});
} else {
make_standard_return();
}
},
__("Return Qty"),
__("Make Return Entry")
);
} else {
make_standard_return();
}
},
__("Create")
);

View File

@@ -774,6 +774,13 @@ class SubcontractingReceipt(SubcontractingController):
make_purchase_receipt(self, save=True, notify=True)
@frappe.whitelist()
def make_subcontract_return_against_rejected_warehouse(source_name):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
return make_return_doc("Subcontracting Receipt", source_name, return_against_rejected_qty=True)
@frappe.whitelist()
def make_subcontract_return(source_name, target_doc=None):
from erpnext.controllers.sales_and_purchase_return import make_return_doc

View File

@@ -1196,6 +1196,136 @@ class TestSubcontractingReceipt(FrappeTestCase):
scr.cancel()
self.assertTrue(scr.docstatus == 2)
def test_subcontract_return_from_rejected_warehouse(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
make_subcontract_return_against_rejected_warehouse,
)
# Create subcontracted item
fg_item = make_item(
"_Test Subcontract Item Return from Rejected Warehouse",
properties={
"is_stock_item": 1,
"is_sub_contracted_item": 1,
},
).name
# Create service item
service_item = make_item(
"_Test Service Item Return from Rejected Warehouse", properties={"is_stock_item": 0}
).name
# Create BOM for the subcontracted item with required raw materials
rm_item1 = make_item(
"_Test RM Item 1 Return from Rejected Warehouse", properties={"is_stock_item": 1}
).name
rm_item2 = make_item(
"_Test RM Item 2 Return from Rejected Warehouse", properties={"is_stock_item": 1}
).name
make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2])
# Create warehouses
rejected_warehouse = create_warehouse("_Test Subcontract Rejected Warehouse Return Qty Warehouse")
# Create service items for subcontracting order
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": service_item,
"qty": 10,
"rate": 100,
"fg_item": fg_item,
"fg_item_qty": 10,
},
]
# Create Subcontracting Order
sco = get_subcontracting_order(service_items=service_items)
# Stock raw materials
make_stock_entry(item_code=rm_item1, qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)
make_stock_entry(item_code=rm_item2, qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)
# Transfer raw materials
rm_items = get_rm_items(sco.supplied_items)
itemwise_details = make_stock_in_entry(rm_items=rm_items)
make_stock_transfer_entry(
sco_no=sco.name,
rm_items=rm_items,
itemwise_details=copy.deepcopy(itemwise_details),
)
# Step 1: Create Subcontracting Receipt with rejected quantity
sr = make_subcontracting_receipt(sco.name)
sr.items[0].qty = 8 # Accepted quantity
sr.items[0].rejected_qty = 2
sr.items[0].rejected_warehouse = rejected_warehouse
sr.save()
sr.submit()
# Verify initial state
sr.reload()
self.assertEqual(sr.items[0].qty, 8)
self.assertEqual(sr.items[0].rejected_qty, 2)
self.assertEqual(sr.items[0].rejected_warehouse, rejected_warehouse)
# Step 2: Create Subcontract Return from Rejected Warehouse
sr_return = make_subcontract_return_against_rejected_warehouse(sr.name)
# Verify the return document properties
self.assertEqual(sr_return.doctype, "Subcontracting Receipt")
self.assertEqual(sr_return.is_return, 1)
self.assertEqual(sr_return.return_against, sr.name)
# Verify item details in return document
self.assertEqual(len(sr_return.items), 1)
self.assertEqual(sr_return.items[0].item_code, fg_item)
self.assertEqual(sr_return.items[0].warehouse, rejected_warehouse)
self.assertEqual(sr_return.items[0].qty, -2.0) # Negative for return
self.assertEqual(sr_return.items[0].rejected_qty, 0.0)
self.assertEqual(sr_return.items[0].rejected_warehouse, "")
# Check specific fields that should be set for subcontracting returns
self.assertEqual(sr_return.items[0].subcontracting_order, sco.name)
self.assertEqual(sr_return.items[0].subcontracting_order_item, sr.items[0].subcontracting_order_item)
self.assertEqual(sr_return.items[0].return_qty_from_rejected_warehouse, 1)
# For returns from rejected warehouse, supplied_items might be empty initially
# They might get populated when the document is saved/submitted
# Or they might not be needed since we're returning finished goods
# Save and submit the return
sr_return.save()
sr_return.submit()
# Verify final state
sr_return.reload()
self.assertEqual(sr_return.docstatus, 1)
self.assertEqual(sr_return.status, "Return")
# Verify stock ledger entries for the return
sle = frappe.get_all(
"Stock Ledger Entry",
filters={
"voucher_type": "Subcontracting Receipt",
"voucher_no": sr_return.name,
"warehouse": rejected_warehouse,
},
fields=["item_code", "actual_qty", "warehouse"],
)
self.assertEqual(len(sle), 1)
self.assertEqual(sle[0].item_code, fg_item)
self.assertEqual(sle[0].actual_qty, -2.0) # Outward entry from rejected warehouse
self.assertEqual(sle[0].warehouse, rejected_warehouse)
# Verify that the original document's rejected quantity is not affected
sr.reload()
self.assertEqual(sr.items[0].rejected_qty, 2) # Should remain the same
@change_settings("Buying Settings", {"auto_create_purchase_receipt": 1})
def test_auto_create_purchase_receipt(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order