mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-13 03:45:08 +00:00
Merge pull request #50333 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user