mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-16 16:08:39 +00:00
Merge pull request #56360 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frappe.ui.form.on("Bank Guarantee", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("reference_doctype", function () {
|
||||
return {
|
||||
filters: {
|
||||
name: ["in", ["Sales Order", "Purchase Order"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.set_query("bank_account", function () {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "ACC-BG-.YYYY.-.#####",
|
||||
"creation": "2016-12-17 10:43:35.731631",
|
||||
"doctype": "DocType",
|
||||
@@ -50,8 +51,7 @@
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Document Type",
|
||||
"options": "DocType",
|
||||
"read_only": 1
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_docname",
|
||||
@@ -60,14 +60,14 @@
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Receiving\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"label": "Customer",
|
||||
"options": "Customer"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Providing\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
|
||||
"fieldname": "supplier",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier",
|
||||
@@ -217,11 +217,11 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-26 00:38:17.584694",
|
||||
"modified": "2026-05-25 18:12:10.768835",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Guarantee",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -103,8 +103,8 @@ class Budget(Document):
|
||||
elif account_details.report_type != "Profit and Loss":
|
||||
frappe.throw(
|
||||
_(
|
||||
"Budget cannot be assigned against {0}, as it's not an Income or Expense account"
|
||||
).format(d.account)
|
||||
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
|
||||
).format(self.account)
|
||||
)
|
||||
|
||||
if d.account in account_list:
|
||||
@@ -425,11 +425,11 @@ def get_ordered_amount(args):
|
||||
|
||||
|
||||
def get_other_condition(args, for_doc):
|
||||
condition = "expense_account = '%s'" % (args.expense_account)
|
||||
condition = f"expense_account = {frappe.db.escape(args.expense_account)}"
|
||||
budget_against_field = args.get("budget_against_field")
|
||||
|
||||
if budget_against_field and args.get(budget_against_field):
|
||||
condition += f" and child.{budget_against_field} = '{args.get(budget_against_field)}'"
|
||||
condition += f" and child.{budget_against_field} = {frappe.db.escape(args.get(budget_against_field))}"
|
||||
|
||||
if args.get("fiscal_year"):
|
||||
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
|
||||
@@ -437,8 +437,7 @@ def get_other_condition(args, for_doc):
|
||||
"Fiscal Year", args.get("fiscal_year"), ["year_start_date", "year_end_date"]
|
||||
)
|
||||
|
||||
condition += f""" and parent.{date_field}
|
||||
between '{start_date}' and '{end_date}' """
|
||||
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
|
||||
|
||||
return condition
|
||||
|
||||
|
||||
@@ -619,6 +619,10 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
def get_account_details(
|
||||
company, posting_date, account, party_type=None, party=None, rounding_loss_allowance: float | None = None
|
||||
):
|
||||
if not account:
|
||||
return
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
if not (company and posting_date):
|
||||
frappe.throw(_("Company and Posting Date is mandatory"))
|
||||
|
||||
|
||||
@@ -1184,7 +1184,11 @@ class JournalEntry(AccountsController):
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
def get_values(self):
|
||||
cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else ""
|
||||
cond = (
|
||||
f" and outstanding_amount <= {flt(self.write_off_amount)}"
|
||||
if flt(self.write_off_amount) > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
if self.write_off_based_on == "Accounts Receivable":
|
||||
return frappe.db.sql(
|
||||
|
||||
@@ -769,17 +769,21 @@ frappe.ui.form.on("Payment Entry", {
|
||||
frm.set_paid_amount_based_on_received_amount = true;
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
|
||||
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
if (frm.doc.paid_amount && frm.doc.source_exchange_rate) {
|
||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
|
||||
// target exchange rate should always be same as source if both account currencies is same
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
|
||||
frm.set_value("received_amount", frm.doc.paid_amount);
|
||||
} else {
|
||||
frm.set_value(
|
||||
"paid_amount",
|
||||
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
|
||||
);
|
||||
const target_rate =
|
||||
flt(frm.doc.target_exchange_rate) ||
|
||||
(company_currency == frm.doc.paid_to_account_currency ? 1 : 0);
|
||||
if (target_rate) {
|
||||
frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate);
|
||||
}
|
||||
}
|
||||
|
||||
// set_unallocated_amount is called by below method,
|
||||
@@ -795,18 +799,23 @@ frappe.ui.form.on("Payment Entry", {
|
||||
target_exchange_rate: function (frm) {
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
|
||||
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
if (
|
||||
!frm.doc.source_exchange_rate &&
|
||||
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
|
||||
) {
|
||||
if (frm.doc.received_amount && frm.doc.target_exchange_rate) {
|
||||
frm.set_value(
|
||||
"base_received_amount",
|
||||
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
||||
frm.set_value("paid_amount", frm.doc.received_amount);
|
||||
} else {
|
||||
frm.set_value(
|
||||
"received_amount",
|
||||
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
const source_rate =
|
||||
flt(frm.doc.source_exchange_rate) ||
|
||||
(company_currency == frm.doc.paid_from_account_currency ? 1 : 0);
|
||||
if (source_rate) {
|
||||
frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate);
|
||||
}
|
||||
}
|
||||
|
||||
// set_unallocated_amount is called by below method,
|
||||
|
||||
@@ -1197,9 +1197,9 @@ class PaymentEntry(AccountsController):
|
||||
continue
|
||||
|
||||
if tax.add_deduct_tax == "Add":
|
||||
included_taxes += tax.base_tax_amount
|
||||
included_taxes += flt(tax.base_tax_amount)
|
||||
else:
|
||||
included_taxes -= tax.base_tax_amount
|
||||
included_taxes -= flt(tax.base_tax_amount)
|
||||
|
||||
return included_taxes
|
||||
|
||||
|
||||
@@ -1118,6 +1118,27 @@ class TestPaymentEntry(FrappeTestCase):
|
||||
|
||||
self.assertEqual(gl_entries, expected_gl_entries)
|
||||
|
||||
def test_payment_entry_with_inclusive_tax(self):
|
||||
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
|
||||
payment_entry = create_payment_entry(paid_amount=1180)
|
||||
payment_entry.append(
|
||||
"taxes",
|
||||
{
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"charge_type": "On Paid Amount",
|
||||
"rate": 18,
|
||||
"included_in_paid_amount": 1,
|
||||
"add_deduct_tax": "Add",
|
||||
"description": "Service Tax",
|
||||
},
|
||||
)
|
||||
payment_entry.save()
|
||||
payment_entry.submit()
|
||||
|
||||
# 1180 incl 18% => 1000 base + 180 tax
|
||||
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
|
||||
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
|
||||
|
||||
def test_payment_entry_against_onhold_purchase_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
|
||||
@@ -455,8 +455,8 @@ class SalesInvoice(SellingController):
|
||||
self.calculate_taxes_and_totals()
|
||||
|
||||
def before_save(self):
|
||||
self.set_account_for_mode_of_payment()
|
||||
self.set_paid_amount()
|
||||
self.set_account_for_mode_of_payment()
|
||||
|
||||
def before_submit(self):
|
||||
self.add_remarks()
|
||||
@@ -791,6 +791,13 @@ class SalesInvoice(SellingController):
|
||||
def set_paid_amount(self):
|
||||
paid_amount = 0.0
|
||||
base_paid_amount = 0.0
|
||||
|
||||
if not cint(self.is_pos) and self.is_return:
|
||||
self.set("payments", [])
|
||||
self.paid_amount = paid_amount
|
||||
self.base_paid_amount = base_paid_amount
|
||||
return
|
||||
|
||||
for data in self.payments:
|
||||
data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount"))
|
||||
paid_amount += data.amount
|
||||
|
||||
@@ -1049,6 +1049,21 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
self.assertEqual(pos_return.get("payments")[0].amount, -500)
|
||||
self.assertEqual(pos_return.get("payments")[1].amount, -500)
|
||||
|
||||
def test_non_pos_return_clears_payment_rows(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
si_return = make_sales_return(si.name)
|
||||
si_return.insert()
|
||||
|
||||
self.assertEqual(si_return.is_pos, 0)
|
||||
self.assertEqual(si_return.get("payments"), [])
|
||||
self.assertEqual(si_return.paid_amount, 0)
|
||||
|
||||
def test_pos_change_amount(self):
|
||||
make_pos_profile(
|
||||
company="_Test Company with perpetual inventory",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Purchase Invoice",
|
||||
"dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]",
|
||||
"dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Incoming Bills",
|
||||
"modified": "2024-11-20 19:08:37.043777",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Incoming Bills",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Payment Entry",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\",false]]",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Incoming Payment",
|
||||
"modified": "2020-07-22 13:06:20.237689",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Incoming Payment",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Sales Invoice",
|
||||
"dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]",
|
||||
"dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Outgoing Bills",
|
||||
"modified": "2020-07-22 13:07:19.633101",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Outgoing Bills",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Payment Entry",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\",false]]",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Outgoing Payment",
|
||||
"modified": "2020-07-22 12:49:34.942896",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Outgoing Payment",
|
||||
|
||||
@@ -922,6 +922,15 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
if party_type == "Supplier":
|
||||
info["total_unpaid"] = -1 * info["total_unpaid"]
|
||||
|
||||
if info["total_unpaid"] < 0:
|
||||
info["balance_label"] = (
|
||||
"Total Advance Paid" if party_type == "Supplier" else "Total Advance Received"
|
||||
)
|
||||
info["balance_amount"] = abs(info["total_unpaid"])
|
||||
else:
|
||||
info["balance_label"] = "Total Unpaid"
|
||||
info["balance_amount"] = info["total_unpaid"]
|
||||
|
||||
company_wise_info.append(info)
|
||||
|
||||
return company_wise_info
|
||||
|
||||
@@ -146,7 +146,6 @@ def get_appropriate_company(filters):
|
||||
return company
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_invoiced_item_gross_margin(sales_invoice=None, item_code=None, company=None, with_item_data=False):
|
||||
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ class BulkTransactionLog(Document):
|
||||
log_detail = qb.DocType("Bulk Transaction Log Detail")
|
||||
|
||||
has_records = frappe.db.sql(
|
||||
f"select exists (select * from `tabBulk Transaction Log Detail` where date = '{self.name}');"
|
||||
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
|
||||
(self.name,),
|
||||
)[0][0]
|
||||
if not has_records:
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
@@ -5,6 +5,7 @@ def get_data():
|
||||
return {
|
||||
"fieldname": "supplier",
|
||||
"non_standard_fieldnames": {"Payment Entry": "party", "Bank Account": "party"},
|
||||
"dynamic_links": {"party": ["Supplier", "party_type"]},
|
||||
"transactions": [
|
||||
{"label": _("Procurement"), "items": ["Request for Quotation", "Supplier Quotation"]},
|
||||
{"label": _("Orders"), "items": ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]},
|
||||
|
||||
@@ -30,11 +30,15 @@
|
||||
"stock_qty",
|
||||
"sec_break_price_list",
|
||||
"price_list_rate",
|
||||
"base_price_list_rate",
|
||||
"discount_and_margin_section",
|
||||
"margin_type",
|
||||
"margin_rate_or_amount",
|
||||
"rate_with_margin",
|
||||
"col_break_6",
|
||||
"discount_percentage",
|
||||
"discount_amount",
|
||||
"distributed_discount_amount",
|
||||
"col_break_price_list",
|
||||
"base_price_list_rate",
|
||||
"sec_break1",
|
||||
"rate",
|
||||
"amount",
|
||||
@@ -531,10 +535,6 @@
|
||||
"fieldname": "sec_break_price_list",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break_price_list",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "ad_sec_break",
|
||||
@@ -572,13 +572,48 @@
|
||||
"fieldtype": "Currency",
|
||||
"label": "Distributed Discount Amount",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"depends_on": "price_list_rate",
|
||||
"fieldname": "margin_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Margin Type",
|
||||
"options": "\nPercentage\nAmount",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.margin_type && doc.price_list_rate",
|
||||
"fieldname": "margin_rate_or_amount",
|
||||
"fieldtype": "Float",
|
||||
"label": "Margin Rate or Amount",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount || doc.distributed_discount_amount",
|
||||
"fieldname": "discount_and_margin_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Discount and Margin"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount",
|
||||
"fieldname": "rate_with_margin",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Rate With Margin",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break_6",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-06-02 06:22:18.864822",
|
||||
"modified": "2025-06-17 12:05:52.441645",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
@@ -589,4 +624,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ class SupplierQuotationItem(Document):
|
||||
lead_time_days: DF.Int
|
||||
manufacturer: DF.Link | None
|
||||
manufacturer_part_no: DF.Data | None
|
||||
margin_rate_or_amount: DF.Float
|
||||
margin_type: DF.Literal["", "Percentage", "Amount"]
|
||||
material_request: DF.Link | None
|
||||
material_request_item: DF.Data | None
|
||||
net_amount: DF.Currency
|
||||
@@ -52,6 +54,7 @@ class SupplierQuotationItem(Document):
|
||||
project: DF.Link | None
|
||||
qty: DF.Float
|
||||
rate: DF.Currency
|
||||
rate_with_margin: DF.Currency
|
||||
request_for_quotation: DF.Link | None
|
||||
request_for_quotation_item: DF.Data | None
|
||||
sales_order: DF.Link | None
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case
|
||||
from frappe.utils import cstr, flt
|
||||
|
||||
from erpnext.utilities.product import get_item_codes_by_attributes
|
||||
@@ -129,6 +130,53 @@ def validate_is_incremental(numeric_attribute, attribute, value, item):
|
||||
)
|
||||
|
||||
|
||||
def get_attribute_value_renames(item_attribute):
|
||||
"""Return old to new attribute value mappings for renamed Item Attribute Value rows."""
|
||||
if item_attribute.numeric_values:
|
||||
return {}
|
||||
|
||||
db_value = item_attribute.get_doc_before_save()
|
||||
if not db_value:
|
||||
return {}
|
||||
|
||||
old_values = {d.name: d.attribute_value for d in db_value.item_attribute_values}
|
||||
renames = {}
|
||||
|
||||
for row in item_attribute.item_attribute_values:
|
||||
if row.name in old_values and old_values[row.name] != row.attribute_value:
|
||||
renames[old_values[row.name]] = row.attribute_value
|
||||
|
||||
return renames
|
||||
|
||||
|
||||
def update_variant_attribute_values(item_attribute):
|
||||
"""Propagate renamed Item Attribute Values to Item Variant Attribute on variant items."""
|
||||
value_map = get_attribute_value_renames(item_attribute)
|
||||
if not value_map:
|
||||
return
|
||||
|
||||
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
|
||||
item_table = frappe.qb.DocType("Item")
|
||||
attribute_value = item_variant_table.attribute_value
|
||||
attribute_value_case = Case()
|
||||
|
||||
for old_value, new_value in value_map.items():
|
||||
attribute_value_case = attribute_value_case.when(attribute_value == old_value, new_value)
|
||||
|
||||
(
|
||||
frappe.qb.update(item_variant_table)
|
||||
.join(item_table)
|
||||
.on(item_table.name == item_variant_table.parent)
|
||||
.set(attribute_value, attribute_value_case.else_(attribute_value))
|
||||
.where(item_table.variant_of.isnotnull())
|
||||
.where(item_table.variant_of != "")
|
||||
.where(item_variant_table.attribute == item_attribute.name)
|
||||
.where(attribute_value.isin(list(value_map)))
|
||||
).run()
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
|
||||
|
||||
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
|
||||
allow_rename_attribute_value = frappe.db.get_single_value(
|
||||
"Item Variant Settings", "allow_rename_attribute_value"
|
||||
|
||||
@@ -380,6 +380,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
doc.pricing_rules = []
|
||||
doc.return_against = source.name
|
||||
doc.set_warehouse = ""
|
||||
if doctype == "Sales Invoice":
|
||||
doc.is_debit_note = 0
|
||||
if doctype == "Sales Invoice" or doctype == "POS Invoice":
|
||||
doc.is_pos = source.is_pos
|
||||
|
||||
|
||||
@@ -394,9 +394,9 @@ class StatusUpdater(Document):
|
||||
for args in self.status_updater:
|
||||
# condition to include current record (if submit or no if cancel)
|
||||
if self.docstatus == 1:
|
||||
args["cond"] = " or parent='%s'" % self.name.replace('"', '"')
|
||||
args["cond"] = " or parent=%s" % frappe.db.escape(self.name)
|
||||
else:
|
||||
args["cond"] = " and parent!='%s'" % self.name.replace('"', '"')
|
||||
args["cond"] = " and parent!=%s" % frappe.db.escape(self.name)
|
||||
|
||||
self._update_children(args, update_modified)
|
||||
|
||||
@@ -426,9 +426,10 @@ class StatusUpdater(Document):
|
||||
args["second_source_condition"] = frappe.db.sql(
|
||||
""" select ifnull((select sum({second_source_field})
|
||||
from `tab{second_source_dt}`
|
||||
where `{second_join_field}`='{detail_id}'
|
||||
where `{second_join_field}`=%(detail_id)s
|
||||
and (`tab{second_source_dt}`.docstatus=1)
|
||||
{second_source_extra_cond}), 0) """.format(**args)
|
||||
{second_source_extra_cond}), 0) """.format(**args),
|
||||
{"detail_id": args["detail_id"]},
|
||||
)[0][0]
|
||||
|
||||
if args["detail_id"]:
|
||||
@@ -439,9 +440,10 @@ class StatusUpdater(Document):
|
||||
frappe.db.sql(
|
||||
"""
|
||||
(select ifnull(sum({source_field}), 0)
|
||||
from `tab{source_dt}` where `{join_field}`='{detail_id}'
|
||||
from `tab{source_dt}` where `{join_field}`=%(detail_id)s
|
||||
and (docstatus=1 {cond}) {extra_cond})
|
||||
""".format(**args)
|
||||
""".format(**args),
|
||||
{"detail_id": args["detail_id"]},
|
||||
)[0][0]
|
||||
or 0.0
|
||||
)
|
||||
@@ -452,7 +454,8 @@ class StatusUpdater(Document):
|
||||
frappe.db.sql(
|
||||
"""update `tab{target_dt}`
|
||||
set {target_field} = {source_dt_value} {update_modified}
|
||||
where name='{detail_id}'""".format(**args)
|
||||
where name=%(detail_id)s""".format(**args),
|
||||
{"detail_id": args["detail_id"]},
|
||||
)
|
||||
|
||||
def _update_percent_field_in_targets(self, args, update_modified=True):
|
||||
|
||||
@@ -38,7 +38,9 @@ class calculate_taxes_and_totals:
|
||||
|
||||
self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items")
|
||||
|
||||
get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)
|
||||
get_round_off_applicable_accounts(
|
||||
self.doc.company, frappe.flags.round_off_applicable_accounts, self.doc
|
||||
)
|
||||
self.calculate()
|
||||
|
||||
def filter_rows(self):
|
||||
@@ -1128,14 +1130,14 @@ def get_itemised_tax_breakup_html(doc):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_round_off_applicable_accounts(company, account_list):
|
||||
def get_round_off_applicable_accounts(company, account_list, doc=None):
|
||||
# required to set correct region
|
||||
with temporary_flag("company", company):
|
||||
return get_regional_round_off_accounts(company, account_list)
|
||||
return get_regional_round_off_accounts(company, account_list, doc)
|
||||
|
||||
|
||||
@erpnext.allow_regional
|
||||
def get_regional_round_off_accounts(company, account_list):
|
||||
def get_regional_round_off_accounts(company, account_list, doc=None):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
@@ -6,6 +8,28 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde
|
||||
|
||||
|
||||
class TestTaxesAndTotals(FrappeTestCase):
|
||||
def test_regional_round_off_accounts(self):
|
||||
"""
|
||||
Regional overrides cannot extend the list in-place — the return
|
||||
value must be assigned back to frappe.flags.round_off_applicable_accounts.
|
||||
"""
|
||||
test_account = "_Test Round Off Account"
|
||||
|
||||
def mock_regional(company, account_list: list, doc=None) -> list:
|
||||
# Simulates a regional override
|
||||
account_list.extend([test_account])
|
||||
return account_list
|
||||
|
||||
so = make_sales_order(do_not_save=True)
|
||||
|
||||
with patch(
|
||||
"erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts",
|
||||
mock_regional,
|
||||
):
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertIn(test_account, frappe.flags.round_off_applicable_accounts)
|
||||
|
||||
def test_disabling_rounded_total_resets_base_fields(self):
|
||||
"""Disabling rounded total should also clear base rounded values."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.modules.utils import get_module_app
|
||||
from frappe.utils import flt, has_common
|
||||
from frappe.utils import cint, flt, has_common
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
"section_break_13",
|
||||
"carry_forward_communication_and_comments",
|
||||
"column_break_junk",
|
||||
"update_timestamp_on_new_communication"
|
||||
"update_timestamp_on_new_communication",
|
||||
"frappe_crm_section",
|
||||
"enable_frappe_crm_data_synchronization",
|
||||
"column_break_jbzj",
|
||||
"allowed_users"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -105,13 +109,37 @@
|
||||
"fieldname": "enable_opportunity_creation_from_contact_us",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Opportunity Creation from Contact Us"
|
||||
},
|
||||
{
|
||||
"fieldname": "frappe_crm_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Frappe CRM"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_jbzj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;",
|
||||
"fieldname": "allowed_users",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Allowed Users",
|
||||
"options": "Frappe CRM Allowed User",
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_frappe_crm_data_synchronization",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Frappe CRM Data Synchronization",
|
||||
"permlevel": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cog",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-11 23:09:49.750381",
|
||||
"modified": "2026-06-22 01:26:13.474915",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM Settings",
|
||||
@@ -145,6 +173,16 @@
|
||||
"role": "Sales Master Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"permlevel": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
@@ -15,12 +16,16 @@ class CRMSettings(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from erpnext.crm.doctype.frappe_crm_allowed_user.frappe_crm_allowed_user import FrappeCRMAllowedUser
|
||||
|
||||
allow_lead_duplication_based_on_emails: DF.Check
|
||||
allowed_users: DF.TableMultiSelect[FrappeCRMAllowedUser]
|
||||
auto_creation_of_contact: DF.Check
|
||||
campaign_naming_by: DF.Literal["Campaign Name", "Naming Series"]
|
||||
carry_forward_communication_and_comments: DF.Check
|
||||
close_opportunity_after_days: DF.Int
|
||||
default_valid_till: DF.Data | None
|
||||
enable_frappe_crm_data_synchronization: DF.Check
|
||||
enable_opportunity_creation_from_contact_us: DF.Check
|
||||
update_timestamp_on_new_communication: DF.Check
|
||||
# end: auto-generated types
|
||||
@@ -28,6 +33,7 @@ class CRMSettings(Document):
|
||||
def validate(self):
|
||||
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
|
||||
self.validate_enable_opportunity_creation_from_contact_us()
|
||||
self.validate_allowed_users()
|
||||
|
||||
def validate_enable_opportunity_creation_from_contact_us(self):
|
||||
contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled")
|
||||
@@ -38,3 +44,43 @@ class CRMSettings(Document):
|
||||
"Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
|
||||
)
|
||||
)
|
||||
|
||||
def validate_allowed_users(self):
|
||||
if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
|
||||
)
|
||||
)
|
||||
|
||||
def before_save(self):
|
||||
self.clear_allowed_users()
|
||||
|
||||
def on_update(self):
|
||||
self.custom_fields_for_frappe_crm_data_sync()
|
||||
|
||||
def clear_allowed_users(self):
|
||||
if not self.enable_frappe_crm_data_synchronization:
|
||||
self.allowed_users = []
|
||||
|
||||
def custom_fields_for_frappe_crm_data_sync(self):
|
||||
custom_fields = {
|
||||
"Quotation": [
|
||||
{
|
||||
"fieldname": "crm_deal",
|
||||
"fieldtype": "Data",
|
||||
"label": "Frappe CRM Deal",
|
||||
"insert_after": "party_name",
|
||||
}
|
||||
],
|
||||
"Customer": [
|
||||
{
|
||||
"fieldname": "crm_deal",
|
||||
"fieldtype": "Data",
|
||||
"label": "Frappe CRM Deal",
|
||||
"insert_after": "prospect_name",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
create_custom_fields(custom_fields, ignore_validate=True)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-06-22 00:47:12.265968",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"user"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-22 01:49:54.586410",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Frappe CRM Allowed User",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class FrappeCRMAllowedUser(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
user: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
_DOCTYPE_NAME = "Frappe CRM Allowed User"
|
||||
@@ -2,35 +2,12 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_custom_fields_for_frappe_crm():
|
||||
frappe.only_for("System Manager")
|
||||
custom_fields = {
|
||||
"Quotation": [
|
||||
{
|
||||
"fieldname": "crm_deal",
|
||||
"fieldtype": "Data",
|
||||
"label": "Frappe CRM Deal",
|
||||
"insert_after": "party_name",
|
||||
}
|
||||
],
|
||||
"Customer": [
|
||||
{
|
||||
"fieldname": "crm_deal",
|
||||
"fieldtype": "Data",
|
||||
"label": "Frappe CRM Deal",
|
||||
"insert_after": "prospect_name",
|
||||
}
|
||||
],
|
||||
}
|
||||
create_custom_fields(custom_fields, ignore_validate=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_prospect_against_crm_deal():
|
||||
validate_frappe_crm_sync()
|
||||
|
||||
doc = frappe.form_dict
|
||||
prospect = frappe.new_doc("Prospect")
|
||||
prospect.company_name = doc.organization or doc.lead_name
|
||||
@@ -161,6 +138,8 @@ CUSTOMER_ALLOWED_FIELDS = {
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_customer(customer_data=None):
|
||||
validate_frappe_crm_sync()
|
||||
|
||||
if not customer_data:
|
||||
customer_data = frappe.form_dict
|
||||
|
||||
@@ -181,3 +160,21 @@ def create_customer(customer_data=None):
|
||||
except Exception:
|
||||
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
|
||||
pass
|
||||
|
||||
|
||||
def validate_frappe_crm_sync():
|
||||
CRMSettings = frappe.get_single("CRM Settings")
|
||||
if not CRMSettings.enable_frappe_crm_data_synchronization:
|
||||
frappe.throw(
|
||||
_("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.")
|
||||
)
|
||||
|
||||
allowed_users = [d.user for d in CRMSettings.allowed_users]
|
||||
|
||||
if frappe.session.user not in allowed_users:
|
||||
frappe.throw(
|
||||
_(
|
||||
"User not allowed to synchronize data from Frappe CRM on ERPNext. Contact System Manager of ERPNext."
|
||||
),
|
||||
exc=frappe.PermissionError,
|
||||
)
|
||||
|
||||
@@ -72,8 +72,9 @@ frappe.ui.form.on("Job Card", {
|
||||
frm.toggle_enable("for_quantity", !has_stock_entry);
|
||||
|
||||
if (!frm.is_new() && has_items && frm.doc.docstatus < 2) {
|
||||
let to_request = frm.doc.for_quantity > frm.doc.transferred_qty;
|
||||
let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
|
||||
const excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
|
||||
const to_transfer = frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty));
|
||||
const to_request = to_transfer;
|
||||
|
||||
if (to_request || excess_transfer_allowed) {
|
||||
frm.add_custom_button(
|
||||
@@ -85,10 +86,6 @@ frappe.ui.form.on("Job Card", {
|
||||
);
|
||||
}
|
||||
|
||||
// check if any row has untransferred materials
|
||||
// in case of multiple items in JC
|
||||
let to_transfer = frm.doc.items.some((row) => row.transferred_qty < row.required_qty);
|
||||
|
||||
if (to_transfer || excess_transfer_allowed) {
|
||||
frm.add_custom_button(
|
||||
__("Material Transfer"),
|
||||
@@ -120,7 +117,8 @@ frappe.ui.form.on("Job Card", {
|
||||
frm.doc.docstatus == 0 &&
|
||||
!frm.is_new() &&
|
||||
(frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) &&
|
||||
(frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty)
|
||||
(!frm.doc.items.length ||
|
||||
!frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty)))
|
||||
) {
|
||||
// if Job Card is link to Work Order, the job card must not be able to start if Work Order not "Started"
|
||||
// and if stock mvt for WIP is required
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Open\nWork In Progress\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted",
|
||||
"options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -513,7 +513,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-12 12:17:17.750857",
|
||||
"modified": "2026-06-22 11:51:16.526778",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card",
|
||||
|
||||
@@ -107,6 +107,7 @@ class JobCard(Document):
|
||||
status: DF.Literal[
|
||||
"Open",
|
||||
"Work In Progress",
|
||||
"Partially Transferred",
|
||||
"Material Transferred",
|
||||
"On Hold",
|
||||
"Submitted",
|
||||
@@ -927,6 +928,8 @@ class JobCard(Document):
|
||||
|
||||
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty))
|
||||
|
||||
self.set_status(update_status=True)
|
||||
|
||||
def set_transferred_qty(self, update_status=False):
|
||||
"Set total FG Qty in Job Card for which RM was transferred."
|
||||
if not self.items:
|
||||
@@ -980,7 +983,22 @@ class JobCard(Document):
|
||||
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
|
||||
|
||||
if self.docstatus < 2:
|
||||
if flt(self.for_quantity) <= flt(self.transferred_qty):
|
||||
if self.items:
|
||||
item_data = frappe.get_all(
|
||||
"Job Card Item",
|
||||
filters={"parent": self.name},
|
||||
fields=["transferred_qty", "required_qty"],
|
||||
)
|
||||
all_transferred = item_data and all(
|
||||
flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data
|
||||
)
|
||||
any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data)
|
||||
|
||||
if all_transferred:
|
||||
self.status = "Material Transferred"
|
||||
elif any_transferred:
|
||||
self.status = "Partially Transferred"
|
||||
elif flt(self.for_quantity) <= flt(self.transferred_qty):
|
||||
self.status = "Material Transferred"
|
||||
|
||||
if self.time_logs:
|
||||
@@ -1224,12 +1242,13 @@ def time_diff_in_minutes(string_ed_date, string_st_date):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_job_details(start, end, filters=None):
|
||||
def get_job_details(start: str, end: str, filters: str | None = None):
|
||||
events = []
|
||||
|
||||
event_color = {
|
||||
"Completed": "#cdf5a6",
|
||||
"Material Transferred": "#ffdd9e",
|
||||
"Partially Transferred": "#ffe5b4",
|
||||
"Work In Progress": "#D3D3D3",
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ frappe.listview_settings["Job Card"] = {
|
||||
Completed: "green",
|
||||
Cancelled: "red",
|
||||
"Material Transferred": "blue",
|
||||
"Partially Transferred": "yellow",
|
||||
Open: "red",
|
||||
};
|
||||
const status = doc.status || "Open";
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Item Code",
|
||||
"options": "Item"
|
||||
"options": "Item",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "source_warehouse",
|
||||
@@ -107,7 +108,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-12 12:22:18.506904",
|
||||
"modified": "2026-06-23 16:52:37.669110",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card Item",
|
||||
|
||||
@@ -16,7 +16,7 @@ class JobCardItem(Document):
|
||||
|
||||
allow_alternative_item: DF.Check
|
||||
description: DF.Text | None
|
||||
item_code: DF.Link | None
|
||||
item_code: DF.Link
|
||||
item_group: DF.Link | None
|
||||
item_name: DF.Data | None
|
||||
parent: DF.Data
|
||||
|
||||
@@ -1461,6 +1461,68 @@ class TestWorkOrder(FrappeTestCase):
|
||||
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
|
||||
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
|
||||
|
||||
def test_material_transferred_min_fraction_on_partial_pick_list(self):
|
||||
"""Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
|
||||
must reflect the least-transferred required item (the bottleneck), instead of being
|
||||
marked fully transferred prematurely when only some materials are transferred.
|
||||
"""
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
|
||||
)
|
||||
|
||||
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
|
||||
|
||||
# pick-list transfer: For Quantity = 0
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
|
||||
)
|
||||
self.assertEqual(transfer_entry.fg_completed_qty, 0.0)
|
||||
|
||||
for item in transfer_entry.items:
|
||||
full_qty = required_qty[item.item_code]
|
||||
item.qty = full_qty if item.item_code == "_Test Item" else full_qty / 2
|
||||
item.transfer_qty = item.qty
|
||||
transfer_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
transferred_qty = {row.item_code: flt(row.transferred_qty) for row in work_order.required_items}
|
||||
self.assertEqual(transferred_qty["_Test Item"], required_qty["_Test Item"])
|
||||
self.assertEqual(
|
||||
transferred_qty["_Test Item Home Desktop 100"],
|
||||
required_qty["_Test Item Home Desktop 100"] / 2,
|
||||
)
|
||||
# bottleneck fraction = 0.5 -> 0.5 * qty(2) = 1.0
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0)
|
||||
|
||||
def test_material_transferred_full_via_pick_list_flow(self):
|
||||
"""Pick-list flow with every required item fully transferred marks the work order
|
||||
as fully transferred (min fraction = 1.0)."""
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
|
||||
)
|
||||
|
||||
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
|
||||
)
|
||||
self.assertEqual(transfer_entry.fg_completed_qty, 0.0)
|
||||
for item in transfer_entry.items:
|
||||
item.qty = required_qty[item.item_code]
|
||||
item.transfer_qty = item.qty
|
||||
transfer_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
|
||||
|
||||
def test_backflushed_batch_raw_materials_based_on_transferred(self):
|
||||
frappe.db.set_single_value(
|
||||
"Manufacturing Settings",
|
||||
|
||||
@@ -979,17 +979,24 @@ erpnext.work_order = {
|
||||
},
|
||||
|
||||
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
|
||||
this.show_prompt_for_qty_input(frm, purpose)
|
||||
.then((data) => {
|
||||
return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", {
|
||||
const max = this.get_max_transferable_qty(frm, purpose);
|
||||
|
||||
const get_pick_list = (for_qty) =>
|
||||
frappe
|
||||
.xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", {
|
||||
source_name: frm.doc.name,
|
||||
for_qty: data.qty,
|
||||
for_qty: for_qty,
|
||||
})
|
||||
.then((pick_list) => {
|
||||
frappe.model.sync(pick_list);
|
||||
frappe.set_route("Form", pick_list.doctype, pick_list.name);
|
||||
});
|
||||
})
|
||||
.then((pick_list) => {
|
||||
frappe.model.sync(pick_list);
|
||||
frappe.set_route("Form", pick_list.doctype, pick_list.name);
|
||||
});
|
||||
|
||||
if (max <= 0) {
|
||||
get_pick_list(frm.doc.qty);
|
||||
} else {
|
||||
this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
|
||||
}
|
||||
},
|
||||
|
||||
make_consumption_se: function (frm, backflush_raw_materials_based_on) {
|
||||
|
||||
@@ -1241,6 +1241,36 @@ class WorkOrder(Document):
|
||||
"transferred_qty", (transferred_items.get(row.item_code) or 0.0), update_modified=False
|
||||
)
|
||||
|
||||
self.recompute_material_transferred_for_manufacturing(transferred_items)
|
||||
|
||||
def recompute_material_transferred_for_manufacturing(self, transferred_items):
|
||||
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
|
||||
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
|
||||
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
|
||||
sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")
|
||||
if sum_fg_completed_qty:
|
||||
self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
|
||||
return
|
||||
|
||||
# Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
|
||||
# so partial availability does not prematurely mark the work order as fully transferred.
|
||||
required_by_item = {}
|
||||
for row in self.required_items:
|
||||
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
|
||||
continue
|
||||
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
|
||||
if not required_by_item:
|
||||
return
|
||||
|
||||
min_fraction = min(
|
||||
flt(transferred_items.get(item_code) or 0) / required_qty
|
||||
for item_code, required_qty in required_by_item.items()
|
||||
)
|
||||
min_fraction = min(min_fraction, 1.0)
|
||||
material_transferred = min_fraction * flt(self.qty)
|
||||
self.db_set("material_transferred_for_manufacturing", material_transferred)
|
||||
|
||||
def update_returned_qty(self):
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
@@ -207,7 +207,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
"method": "erpnext.controllers.taxes_and_totals.get_round_off_applicable_accounts",
|
||||
"args": {
|
||||
"company": me.frm.doc.company,
|
||||
"account_list": frappe.flags.round_off_applicable_accounts
|
||||
"account_list": frappe.flags.round_off_applicable_accounts,
|
||||
"doc": me.frm.doc,
|
||||
},
|
||||
callback(r) {
|
||||
if (r.message) {
|
||||
|
||||
@@ -93,11 +93,19 @@ $.extend(erpnext.utils, {
|
||||
]),
|
||||
"blue"
|
||||
);
|
||||
var info = company_wise_info[0];
|
||||
var is_advance = info.balance_label !== "Total Unpaid";
|
||||
var indicator_label =
|
||||
info.balance_label === "Total Advance Paid"
|
||||
? __("Total Advance Paid: {0}", [format_currency(info.balance_amount, info.currency)])
|
||||
: info.balance_label === "Total Advance Received"
|
||||
? __("Total Advance Received: {0}", [
|
||||
format_currency(info.balance_amount, info.currency),
|
||||
])
|
||||
: __("Total Unpaid: {0}", [format_currency(info.balance_amount, info.currency)]);
|
||||
frm.dashboard.add_indicator(
|
||||
__("Total Unpaid: {0}", [
|
||||
format_currency(company_wise_info[0].total_unpaid, company_wise_info[0].currency),
|
||||
]),
|
||||
company_wise_info[0].total_unpaid ? "orange" : "green"
|
||||
indicator_label,
|
||||
is_advance ? "green" : info.balance_amount ? "orange" : "green"
|
||||
);
|
||||
|
||||
if (company_wise_info[0].loyalty_points) {
|
||||
@@ -140,7 +148,14 @@ $.extend(erpnext.utils, {
|
||||
frm.dashboard.stats_area_row.addClass("flex");
|
||||
frm.dashboard.stats_area_row.css("flex-wrap", "wrap");
|
||||
|
||||
var color = info.total_unpaid ? "orange" : "green";
|
||||
var is_advance = info.balance_label !== "Total Unpaid";
|
||||
var color = is_advance ? "green" : info.balance_amount ? "orange" : "green";
|
||||
var balance_label_text =
|
||||
info.balance_label === "Total Advance Paid"
|
||||
? __("Total Advance Paid")
|
||||
: info.balance_label === "Total Advance Received"
|
||||
? __("Total Advance Received")
|
||||
: __("Total Unpaid");
|
||||
|
||||
var indicator = $(
|
||||
'<div class="flex-column col-xs-6">' +
|
||||
@@ -154,8 +169,10 @@ $.extend(erpnext.utils, {
|
||||
'<div class="badge-link small" style="margin-bottom:10px">' +
|
||||
'<span class="indicator ' +
|
||||
color +
|
||||
'">Total Unpaid: ' +
|
||||
format_currency(info.total_unpaid, info.currency) +
|
||||
'">' +
|
||||
balance_label_text +
|
||||
": " +
|
||||
format_currency(info.balance_amount, info.currency) +
|
||||
"</span></div>" +
|
||||
"</div>"
|
||||
).appendTo(frm.dashboard.stats_area_row);
|
||||
|
||||
@@ -11,7 +11,10 @@ def get_data():
|
||||
"Bank Account": "party",
|
||||
"Subscription": "party",
|
||||
},
|
||||
"dynamic_links": {"party_name": ["Customer", "quotation_to"]},
|
||||
"dynamic_links": {
|
||||
"party_name": ["Customer", "quotation_to"],
|
||||
"party": ["Customer", "party_type"],
|
||||
},
|
||||
"transactions": [
|
||||
{"label": _("Pre Sales"), "items": ["Opportunity", "Quotation"]},
|
||||
{"label": _("Orders"), "items": ["Sales Order", "Delivery Note", "Sales Invoice"]},
|
||||
|
||||
@@ -40,15 +40,6 @@ erpnext.PointOfSale.Controller = class {
|
||||
in_list_view: 1,
|
||||
label: __("Opening Amount"),
|
||||
options: "company:company_currency",
|
||||
onchange: function () {
|
||||
dialog.fields_dict.balance_details.df.data.some((d) => {
|
||||
if (d.idx == this.doc.idx) {
|
||||
d.opening_amount = this.value;
|
||||
dialog.fields_dict.balance_details.grid.refresh();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
const fetch_pos_payment_methods = () => {
|
||||
|
||||
@@ -14,6 +14,9 @@ def execute(filters=None):
|
||||
days_since_last_order = filters.get("days_since_last_order")
|
||||
doctype = filters.get("doctype")
|
||||
|
||||
if doctype not in ("Sales Order", "Sales Invoice"):
|
||||
frappe.throw(_("Invalid value {0} for 'Doctype'").format(doctype))
|
||||
|
||||
if cint(days_since_last_order) <= 0:
|
||||
frappe.throw(_("'Days Since Last Order' must be greater than or equal to zero"))
|
||||
|
||||
|
||||
@@ -427,14 +427,16 @@ class Analytics:
|
||||
break
|
||||
|
||||
def get_groups(self):
|
||||
if self.filters.tree_type == "Territory":
|
||||
parent = "parent_territory"
|
||||
if self.filters.tree_type == "Customer Group":
|
||||
parent = "parent_customer_group"
|
||||
if self.filters.tree_type == "Item Group":
|
||||
parent = "parent_item_group"
|
||||
if self.filters.tree_type == "Supplier Group":
|
||||
parent = "parent_supplier_group"
|
||||
parent_field_map = {
|
||||
"Territory": "parent_territory",
|
||||
"Customer Group": "parent_customer_group",
|
||||
"Item Group": "parent_item_group",
|
||||
"Supplier Group": "parent_supplier_group",
|
||||
}
|
||||
if self.filters.tree_type not in parent_field_map:
|
||||
frappe.throw(_("Invalid Tree Type {0}").format(self.filters.tree_type))
|
||||
|
||||
parent = parent_field_map[self.filters.tree_type]
|
||||
|
||||
self.depth_map = frappe._dict()
|
||||
|
||||
@@ -453,6 +455,9 @@ class Analytics:
|
||||
def get_teams(self):
|
||||
self.depth_map = frappe._dict()
|
||||
|
||||
if not frappe.db.exists("DocType", self.filters.doc_type):
|
||||
frappe.throw(_("Invalid Document Type {0}").format(self.filters.doc_type))
|
||||
|
||||
self.group_entries = frappe.db.sql(
|
||||
f""" select * from (select "Order Types" as name, 0 as lft,
|
||||
2 as rgt, '' as parent union select distinct order_type as name, 1 as lft, 1 as rgt, "Order Types" as parent
|
||||
|
||||
@@ -120,7 +120,9 @@ class AuthorizationControl(TransactionBase):
|
||||
if val == 1:
|
||||
add_cond += " and system_user = {}".format(frappe.db.escape(session["user"]))
|
||||
elif val == 2:
|
||||
add_cond += " and system_role IN %s" % ("('" + "','".join(frappe.get_roles()) + "')")
|
||||
add_cond += " and system_role IN (%s)" % ", ".join(
|
||||
frappe.db.escape(r) for r in frappe.get_roles()
|
||||
)
|
||||
else:
|
||||
add_cond += " and ifnull(system_user,'') = '' and ifnull(system_role,'') = ''"
|
||||
|
||||
@@ -203,8 +205,8 @@ class AuthorizationControl(TransactionBase):
|
||||
and docstatus != 2
|
||||
""".format(
|
||||
"%s",
|
||||
"'" + "','".join(frappe.get_roles()) + "'",
|
||||
"'" + "','".join(final_based_on) + "'",
|
||||
", ".join(frappe.db.escape(r) for r in frappe.get_roles()),
|
||||
", ".join(frappe.db.escape(b) for b in final_based_on),
|
||||
"%s",
|
||||
),
|
||||
(doctype_name, company),
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"disabled",
|
||||
"column_break_24",
|
||||
"use_batchwise_valuation",
|
||||
"allow_negative_stock_for_batch",
|
||||
"sb_batch",
|
||||
"batch_id",
|
||||
"item",
|
||||
@@ -202,6 +203,14 @@
|
||||
"label": "Use Batch-wise Valuation",
|
||||
"read_only": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, the system will allow negative stock entries for this batch, overriding the 'Allow negative stock for Batch' setting in Stock Settings. This may lead to incorrect valuation rates, so it is recommended to avoid using this option.",
|
||||
"fieldname": "allow_negative_stock_for_batch",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Negative Stock for Batch",
|
||||
"no_copy": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-archive",
|
||||
@@ -209,7 +218,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"max_attachments": 5,
|
||||
"modified": "2026-06-16 16:01:26.556324",
|
||||
"modified": "2026-06-17 12:17:28.339975",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Batch",
|
||||
|
||||
@@ -95,6 +95,7 @@ class Batch(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
allow_negative_stock_for_batch: DF.Check
|
||||
batch_id: DF.Data
|
||||
batch_qty: DF.Float
|
||||
description: DF.SmallText | None
|
||||
|
||||
@@ -360,6 +360,89 @@ class TestItem(FrappeTestCase):
|
||||
self.assertRaises(InvalidItemAttributeValueError, attribute.save)
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_rename_attribute_value_updates_variants(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
|
||||
variant.save()
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.attribute_value = "Larger"
|
||||
break
|
||||
|
||||
def restore_test_size_large():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
if row.attribute_value == "Larger":
|
||||
row.attribute_value = "Large"
|
||||
break
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_large)
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Item Variant Attribute",
|
||||
{"parent": variant.name, "attribute": "Test Size"},
|
||||
"attribute_value",
|
||||
),
|
||||
"Larger",
|
||||
)
|
||||
|
||||
def test_swapped_attribute_value_renames_update_variants(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-S", force=1)
|
||||
|
||||
large_variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
|
||||
large_variant.save()
|
||||
|
||||
small_variant = create_variant("_Test Variant Item", {"Test Size": "Small"})
|
||||
small_variant.save()
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
original_values = {row.name: row.attribute_value for row in attribute.item_attribute_values}
|
||||
|
||||
def restore_test_size_values():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
row.attribute_value = original_values[row.name]
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_values)
|
||||
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.attribute_value = "Small"
|
||||
elif row.attribute_value == "Small":
|
||||
row.attribute_value = "Large"
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Item Variant Attribute",
|
||||
{"parent": large_variant.name, "attribute": "Test Size"},
|
||||
"attribute_value",
|
||||
),
|
||||
"Small",
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Item Variant Attribute",
|
||||
{"parent": small_variant.name, "attribute": "Test Size"},
|
||||
"attribute_value",
|
||||
),
|
||||
"Large",
|
||||
)
|
||||
|
||||
def test_make_item_variant(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.item_variant import (
|
||||
InvalidItemAttributeValueError,
|
||||
update_variant_attribute_values,
|
||||
validate_is_incremental,
|
||||
validate_item_attribute_value,
|
||||
)
|
||||
@@ -47,6 +48,7 @@ class ItemAttribute(Document):
|
||||
self.validate_duplication()
|
||||
|
||||
def on_update(self):
|
||||
update_variant_attribute_values(self)
|
||||
self.validate_exising_items()
|
||||
self.set_enabled_disabled_in_items()
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ class MaterialRequest(BuyingController):
|
||||
|
||||
def check_modified_date(self):
|
||||
mod_db = frappe.db.sql("""select modified from `tabMaterial Request` where name = %s""", self.name)
|
||||
date_diff = frappe.db.sql(f"""select TIMEDIFF('{mod_db[0][0]}', '{cstr(self.modified)}')""")
|
||||
date_diff = frappe.db.sql("""select TIMEDIFF(%s, %s)""", (mod_db[0][0], cstr(self.modified)))
|
||||
|
||||
if date_diff and date_diff[0][0]:
|
||||
frappe.throw(_("{0} {1} has been modified. Please refresh.").format(_(self.doctype), self.name))
|
||||
|
||||
@@ -995,6 +995,52 @@ class TestMaterialRequest(FrappeTestCase):
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
def test_mr_status_for_mixed_direct_and_transit_transfer(self):
|
||||
material_request = make_material_request(
|
||||
material_request_type="Material Transfer",
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
qty=5,
|
||||
)
|
||||
|
||||
in_transit_wh = get_in_transit_warehouse(material_request.company)
|
||||
|
||||
# Make stock available
|
||||
self._insert_stock_entry(20.0, 20.0)
|
||||
|
||||
# Direct Transfer for 3 Qty
|
||||
direct_transfer = make_stock_entry(material_request.name)
|
||||
direct_transfer.items[0].update(
|
||||
{
|
||||
"qty": 3,
|
||||
"transfer_qty": 3,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
direct_transfer.save()
|
||||
direct_transfer.submit()
|
||||
|
||||
# In Transit Transfer for remaining 2 Qty
|
||||
transit_transfer = make_in_transit_stock_entry(material_request.name, in_transit_wh)
|
||||
transit_transfer.items[0].update(
|
||||
{
|
||||
"qty": 2,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
transit_transfer.save()
|
||||
transit_transfer.submit()
|
||||
|
||||
# Complete End Transit
|
||||
end_transit = make_stock_in_entry(transit_transfer.name)
|
||||
end_transit.save()
|
||||
end_transit.submit()
|
||||
|
||||
material_request.reload()
|
||||
|
||||
self.assertEqual(material_request.per_ordered, 100)
|
||||
self.assertEqual(material_request.status, "Transferred")
|
||||
self.assertEqual(material_request.transfer_status, "Completed")
|
||||
|
||||
|
||||
def get_in_transit_warehouse(company):
|
||||
if not frappe.db.exists("Warehouse Type", "Transit"):
|
||||
|
||||
@@ -1577,7 +1577,7 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
|
||||
stock_entry.from_bom = 1
|
||||
stock_entry.bom_no = work_order.bom_no
|
||||
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
|
||||
stock_entry.fg_completed_qty = pick_list.for_qty
|
||||
stock_entry.fg_completed_qty = 0
|
||||
if work_order.bom_no:
|
||||
stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")
|
||||
|
||||
|
||||
@@ -1508,7 +1508,7 @@ class SerialandBatchBundle(Document):
|
||||
def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None):
|
||||
from erpnext.stock.stock_ledger import NegativeStockError
|
||||
|
||||
if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"):
|
||||
if allow_negative_stock_for_batch(batch_no):
|
||||
return
|
||||
|
||||
date_msg = ""
|
||||
@@ -1519,7 +1519,7 @@ class SerialandBatchBundle(Document):
|
||||
"""
|
||||
The Batch {0} of an item {1} has negative stock in the warehouse {2}{3}.
|
||||
Please add a stock quantity of {4} to proceed with this entry.
|
||||
If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in Stock Settings to proceed.
|
||||
If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in the batch {0} or Stock Settings to proceed.
|
||||
However, enabling this setting may lead to negative stock in the system.
|
||||
So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate."""
|
||||
).format(
|
||||
@@ -2128,6 +2128,19 @@ def combine_datetime(date, time=None):
|
||||
return get_combine_datetime(date, time)
|
||||
|
||||
|
||||
def allow_negative_stock_for_batch(batch_no):
|
||||
"""Return whether negative stock is allowed for the given batch.
|
||||
|
||||
The batch-level setting takes priority: if `allow_negative_stock_for_batch`
|
||||
is enabled on the Batch, negative stock is allowed regardless of Stock Settings.
|
||||
Otherwise, fall back to the `allow_negative_stock_for_batch` Stock Setting.
|
||||
"""
|
||||
if batch_no and frappe.db.get_value("Batch", batch_no, "allow_negative_stock_for_batch"):
|
||||
return True
|
||||
|
||||
return bool(frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"))
|
||||
|
||||
|
||||
def get_batch(item_code):
|
||||
from erpnext.stock.doctype.batch.batch import make_batch
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
cur_frm.add_fetch("customer", "customer_name", "customer_name");
|
||||
cur_frm.add_fetch("supplier", "supplier_name", "supplier_name");
|
||||
|
||||
cur_frm.add_fetch("item_code", "item_name", "item_name");
|
||||
cur_frm.add_fetch("item_code", "description", "description");
|
||||
cur_frm.add_fetch("item_code", "item_group", "item_group");
|
||||
cur_frm.add_fetch("item_code", "brand", "brand");
|
||||
|
||||
cur_frm.cscript.onload = function () {
|
||||
cur_frm.set_query("item_code", function () {
|
||||
return erpnext.queries.item({ is_stock_item: 1, has_serial_no: 1 });
|
||||
});
|
||||
};
|
||||
|
||||
frappe.ui.form.on("Serial No", "refresh", function (frm) {
|
||||
frm.toggle_enable("item_code", frm.doc.__islocal);
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Serial No", {
|
||||
setup(frm) {
|
||||
frm.add_fetch("customer", "customer_name", "customer_name");
|
||||
frm.add_fetch("supplier", "supplier_name", "supplier_name");
|
||||
frm.add_fetch("item_code", "item_name", "item_name");
|
||||
frm.add_fetch("item_code", "description", "description");
|
||||
frm.add_fetch("item_code", "item_group", "item_group");
|
||||
frm.add_fetch("item_code", "brand", "brand");
|
||||
|
||||
frm.set_query("item_code", function () {
|
||||
return erpnext.queries.item({ is_stock_item: 1, has_serial_no: 1 });
|
||||
});
|
||||
|
||||
frm.set_query("work_order", () => {
|
||||
return {
|
||||
filters: {
|
||||
docstatus: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
refresh(frm) {
|
||||
frm.toggle_enable("item_code", frm.doc.__islocal);
|
||||
frm.trigger("view_ledgers");
|
||||
},
|
||||
|
||||
|
||||
@@ -1146,10 +1146,12 @@ class StockEntry(StockController):
|
||||
if self.purpose not in ["Manufacture", "Material Transfer for Manufacture"]:
|
||||
return
|
||||
|
||||
if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
|
||||
if not self.fg_completed_qty:
|
||||
if self.work_order and self.purpose == "Material Transfer for Manufacture":
|
||||
self._validate_no_excess_transfer()
|
||||
return
|
||||
|
||||
if not self.fg_completed_qty:
|
||||
if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
|
||||
return
|
||||
|
||||
raw_materials = self.get_bom_raw_materials(self.fg_completed_qty)
|
||||
@@ -1174,6 +1176,59 @@ class StockEntry(StockController):
|
||||
title=_("Missing Item"),
|
||||
)
|
||||
|
||||
def _validate_no_excess_transfer(self):
|
||||
if self.is_return:
|
||||
return
|
||||
|
||||
if (
|
||||
frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on")
|
||||
== "Material Transferred for Manufacture"
|
||||
):
|
||||
return
|
||||
|
||||
wo = self.pro_doc
|
||||
if not wo:
|
||||
return
|
||||
|
||||
pending_by_item = {}
|
||||
for r in wo.required_items:
|
||||
pending_by_item[r.item_code] = (
|
||||
pending_by_item.get(r.item_code, 0.0) + flt(r.required_qty) - flt(r.transferred_qty)
|
||||
)
|
||||
|
||||
transfer_by_item = {}
|
||||
first_row_by_item = {}
|
||||
for item in self.items:
|
||||
if not item.s_warehouse:
|
||||
continue
|
||||
|
||||
key = (
|
||||
item.item_code if item.item_code in pending_by_item else getattr(item, "original_item", None)
|
||||
)
|
||||
if key not in pending_by_item:
|
||||
continue
|
||||
|
||||
transfer_by_item[key] = transfer_by_item.get(key, 0.0) + flt(item.qty)
|
||||
first_row_by_item.setdefault(key, item)
|
||||
|
||||
for key, transfer_qty in transfer_by_item.items():
|
||||
pending_qty = max(0.0, pending_by_item[key])
|
||||
if transfer_qty > pending_qty:
|
||||
item = first_row_by_item[key]
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Cannot transfer {1} {2} of Item {3}. "
|
||||
"Maximum transferable quantity is {4} {2}."
|
||||
).format(
|
||||
item.idx,
|
||||
transfer_qty,
|
||||
item.uom,
|
||||
frappe.bold(item.item_code),
|
||||
pending_qty,
|
||||
),
|
||||
title=_("Excess Material Transfer"),
|
||||
)
|
||||
|
||||
def validate_same_source_target_warehouse_during_material_transfer(self):
|
||||
"""
|
||||
Validate Material Transfer entries where source and target warehouses are identical.
|
||||
@@ -2005,6 +2060,8 @@ class StockEntry(StockController):
|
||||
] += flt(t.base_amount * multiply_based_on) / divide_based_on
|
||||
|
||||
if item_account_wise_additional_cost:
|
||||
precision = self.get_debit_field_precision()
|
||||
|
||||
for d in self.get("items"):
|
||||
for account, amount in item_account_wise_additional_cost.get(
|
||||
(d.item_code, d.name), {}
|
||||
@@ -2012,6 +2069,9 @@ class StockEntry(StockController):
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
amount["amount"] = flt(amount["amount"], precision)
|
||||
amount["base_amount"] = flt(amount["base_amount"], precision)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
@@ -4114,13 +4174,19 @@ def get_batchwise_serial_nos(item_code, row):
|
||||
|
||||
|
||||
def get_transferred_qty(material_request):
|
||||
sed = DocType("Stock Entry Detail")
|
||||
from pypika import Case
|
||||
|
||||
se = frappe.qb.DocType("Stock Entry")
|
||||
sed = frappe.qb.DocType("Stock Entry Detail")
|
||||
completed_qty = Case().when(se.add_to_transit == 1, sed.transferred_qty).else_(sed.transfer_qty)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sed)
|
||||
.inner_join(se)
|
||||
.on(se.name == sed.parent)
|
||||
.select(
|
||||
Sum(sed.transfer_qty).as_("transfer_qty"),
|
||||
Sum(sed.transferred_qty).as_("transferred_qty"),
|
||||
Sum(completed_qty).as_("transferred_qty"),
|
||||
)
|
||||
.where((sed.material_request == material_request) & (sed.docstatus == 1))
|
||||
).run(as_dict=True)
|
||||
|
||||
@@ -547,6 +547,60 @@ class TestStockEntry(FrappeTestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_additional_cost_no_rounding_residual_on_stock_adjustment(self):
|
||||
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
|
||||
warehouse = "Stores - TCP1"
|
||||
items = [
|
||||
make_item(f"_Test Addl Cost Rounding {x}", {"is_stock_item": 1}).name for x in ("A", "B", "C")
|
||||
]
|
||||
|
||||
for item_code in items:
|
||||
make_stock_entry(item_code=item_code, target=warehouse, company=company, qty=100, basic_rate=10)
|
||||
|
||||
transfer = make_stock_entry(company=company, purpose="Material Transfer", do_not_save=True)
|
||||
transfer.from_warehouse = warehouse
|
||||
transfer.to_warehouse = warehouse
|
||||
transfer.items = []
|
||||
for item_code in items:
|
||||
transfer.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code,
|
||||
"qty": 100,
|
||||
"s_warehouse": warehouse,
|
||||
"t_warehouse": warehouse,
|
||||
"uom": "Nos",
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
transfer.append(
|
||||
"additional_costs",
|
||||
{
|
||||
"expense_account": "Expenses Included In Valuation - TCP1",
|
||||
"description": "freight",
|
||||
"amount": 100,
|
||||
},
|
||||
)
|
||||
transfer.insert()
|
||||
transfer.submit()
|
||||
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Stock Entry", "voucher_no": transfer.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
)
|
||||
gl_map = {}
|
||||
for row in gl_entries:
|
||||
account = gl_map.setdefault(row.account, frappe._dict(debit=0.0, credit=0.0))
|
||||
account.debit += row.debit
|
||||
account.credit += row.credit
|
||||
|
||||
self.assertNotIn("Stock Adjustment - TCP1", gl_map)
|
||||
|
||||
stock_in_hand_account = get_inventory_account(company, warehouse)
|
||||
self.assertEqual(flt(gl_map[stock_in_hand_account].debit, 2), 99.99)
|
||||
self.assertEqual(flt(gl_map["Expenses Included In Valuation - TCP1"].credit, 2), 99.99)
|
||||
|
||||
def check_stock_ledger_entries(self, voucher_type, voucher_no, expected_sle):
|
||||
expected_sle.sort(key=lambda x: x[1])
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ class FIFOSlots:
|
||||
if row.voucher_type != "Stock Reconciliation":
|
||||
return
|
||||
|
||||
if not row.batch_no or row.serial_no or row.serial_and_batch_bundle:
|
||||
if row.has_serial_no and (not row.batch_no or row.serial_no or row.serial_and_batch_bundle):
|
||||
if row.voucher_detail_no in self.stock_reco_voucher_wise_count:
|
||||
# Legacy reconciliation with a single SLE has qty_after_transaction and
|
||||
# stock_value_difference without an outward entry, so reset the queue first.
|
||||
@@ -1083,6 +1083,7 @@ class FIFOSlots:
|
||||
(doctype.voucher_type == "Stock Reconciliation")
|
||||
& (doctype.docstatus < 2)
|
||||
& (doctype.is_cancelled == 0)
|
||||
& (item.has_serial_no == 1)
|
||||
)
|
||||
.groupby(doctype.voucher_detail_no)
|
||||
)
|
||||
|
||||
@@ -195,6 +195,67 @@ class TestStockAgeing(FrappeTestCase):
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
self.assertEqual(queue[1][0], 20.0)
|
||||
|
||||
def test_non_serial_stock_reco_decrease_preserves_ageing(self):
|
||||
"""
|
||||
Non-serial stock reconciliation should adjust FIFO by the balance delta.
|
||||
Decreasing stock consumes old slots; increasing stock adds only the new qty.
|
||||
"""
|
||||
|
||||
def make_sle(
|
||||
posting_date,
|
||||
voucher_type,
|
||||
voucher_no,
|
||||
actual_qty,
|
||||
qty_after,
|
||||
voucher_detail_no=None,
|
||||
stock_value_difference=None,
|
||||
):
|
||||
stock_value_difference = actual_qty if stock_value_difference is None else stock_value_difference
|
||||
|
||||
return frappe._dict(
|
||||
name="Flask Item",
|
||||
item_name="Flask Item",
|
||||
description="Flask Item",
|
||||
item_group=None,
|
||||
brand=None,
|
||||
stock_uom="Nos",
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=1,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type=voucher_type,
|
||||
voucher_no=voucher_no,
|
||||
voucher_detail_no=voucher_detail_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=False,
|
||||
serial_no=None,
|
||||
batch_no=None,
|
||||
serial_and_batch_bundle=None,
|
||||
)
|
||||
|
||||
filters = frappe._dict(company="_Test Company", to_date="2026-02-15", ranges=["30", "60", "90"])
|
||||
sle = [
|
||||
make_sle("2025-11-30", "Stock Entry", "001", 100, 100),
|
||||
make_sle("2025-12-31", "Stock Reconciliation", "002", 0, 60, "SRI-DECREASE", -40),
|
||||
make_sle("2026-01-31", "Stock Reconciliation", "003", 0, 90, "SRI-INCREASE", 30),
|
||||
]
|
||||
|
||||
fifo_slots = FIFOSlots(filters, sle)
|
||||
|
||||
def prepare_stock_reco_voucher_wise_count():
|
||||
fifo_slots.stock_reco_voucher_wise_count = frappe._dict({"SRI-DECREASE": 100, "SRI-INCREASE": 60})
|
||||
|
||||
fifo_slots.prepare_stock_reco_voucher_wise_count = prepare_stock_reco_voucher_wise_count
|
||||
|
||||
slots = fifo_slots.generate()
|
||||
queue = slots["Flask Item"]["fifo_queue"]
|
||||
report_data = format_report_data(filters, slots, filters.to_date)
|
||||
|
||||
self.assertEqual(queue, [[60.0, "2025-11-30", 60.0], [30.0, "2026-01-31", 30.0]])
|
||||
self.assertEqual(report_data[0][7:15], [30.0, 30.0, 0.0, 0.0, 60.0, 60.0, 0.0, 0.0])
|
||||
|
||||
def test_sequential_stock_reco_same_warehouse(self):
|
||||
"""
|
||||
Test back to back stock recos (same warehouse).
|
||||
|
||||
@@ -283,7 +283,7 @@ def set_stock_balance_as_per_serial_no(
|
||||
if not posting_time:
|
||||
posting_time = nowtime()
|
||||
|
||||
condition = " and item.name='%s'" % item_code.replace("'", "'") if item_code else ""
|
||||
condition = " and item.name=%s" % frappe.db.escape(item_code, percent=False) if item_code else ""
|
||||
|
||||
bin = frappe.db.sql(
|
||||
"""select bin.item_code, bin.warehouse, bin.actual_qty, item.stock_uom
|
||||
|
||||
@@ -908,6 +908,16 @@ class update_entries_after:
|
||||
and not has_dimensions
|
||||
):
|
||||
# assert
|
||||
if (
|
||||
sle.voucher_detail_no
|
||||
and self.repost_doc
|
||||
and self.repost_doc.get("recalculate_valuation_rate")
|
||||
):
|
||||
source_rate = frappe.get_cached_value(
|
||||
"Stock Reconciliation Item", sle.voucher_detail_no, "valuation_rate"
|
||||
)
|
||||
if source_rate:
|
||||
sle.valuation_rate = source_rate
|
||||
self.wh_data.valuation_rate = sle.valuation_rate
|
||||
self.wh_data.qty_after_transaction = sle.qty_after_transaction
|
||||
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(
|
||||
|
||||
@@ -226,7 +226,6 @@ def set_multiple_status(names, status):
|
||||
@frappe.whitelist()
|
||||
def set_status(name, status):
|
||||
frappe.has_permission("Issue", "write", name, throw=True)
|
||||
|
||||
frappe.db.set_value("Issue", name, "status", status)
|
||||
|
||||
|
||||
|
||||
@@ -524,6 +524,38 @@ class TestFirstResponseTime(TestSetUp):
|
||||
)
|
||||
self.assertEqual(issue.first_response_time, 1.0)
|
||||
|
||||
def _get_no_perm_user(self):
|
||||
email = "test_no_issue_perm@example.com"
|
||||
if not frappe.db.exists("User", email):
|
||||
user = frappe.new_doc("User")
|
||||
user.email = email
|
||||
user.first_name = "No Perm"
|
||||
user.send_welcome_email = 0
|
||||
user.insert(ignore_permissions=True)
|
||||
return email
|
||||
|
||||
def test_set_status_requires_write_permission(self):
|
||||
from erpnext.support.doctype.issue.issue import set_status
|
||||
|
||||
issue = frappe.new_doc("Issue")
|
||||
issue.subject = "_Test Permission Issue"
|
||||
issue.insert(ignore_permissions=True)
|
||||
frappe.set_user(self._get_no_perm_user())
|
||||
self.assertRaises(frappe.PermissionError, set_status, issue.name, "Closed")
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_set_multiple_status_requires_write_permission(self):
|
||||
import json
|
||||
|
||||
from erpnext.support.doctype.issue.issue import set_multiple_status
|
||||
|
||||
issue = frappe.new_doc("Issue")
|
||||
issue.subject = "_Test Permission Issue"
|
||||
issue.insert(ignore_permissions=True)
|
||||
frappe.set_user(self._get_no_perm_user())
|
||||
self.assertRaises(frappe.PermissionError, set_multiple_status, json.dumps([issue.name]), "Closed")
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
|
||||
def create_issue_and_communication(issue_creation, first_responded_on):
|
||||
issue = make_issue(issue_creation, index=1)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<td width="15"></td>
|
||||
<td valign="top" width="24">
|
||||
{% if user.image %}
|
||||
<img class="sender-avatar" width="24" height="24" embed="{{ user.image }}"/>
|
||||
<img class="sender-avatar" width="24" height="24" embed="{{ user.image | e }}"/>
|
||||
{% else %}
|
||||
<div class="sender-avatar-placeholder">
|
||||
{{ user.full_name[0] }}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
], as_dict = True) %}
|
||||
{% if user_details.user_image %}
|
||||
<span class="avatar avatar-small" style="width:32px; height:32px;" title="{{ user_details.full_name }}">
|
||||
<img src="{{ user_details.user_image }}">
|
||||
<img src="{{ user_details.user_image | e }}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="avatar avatar-small" style="width:32px; height:32px;" title="{{ user_details.full_name }}">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
as_dict = True)%}
|
||||
{% if user_details.user_image %}
|
||||
<span class="avatar avatar-small" style="width:32px; height:32px;" title="{{ user_details.full_name }}">
|
||||
<img src="{{ user_details.user_image }}">
|
||||
<img src="{{ user_details.user_image | e }}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="avatar avatar-small" style="width:32px; height:32px;" title="{{ user_details.full_name }}">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
%}
|
||||
{% if user_details.user_image %}
|
||||
<span class="avatar avatar-small" style="width:32px; height:32px;" title="{{ user_details.full_name }}">
|
||||
<img src="{{ user_details.user_image }}">
|
||||
<img src="{{ user_details.user_image | e }}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="avatar avatar-small" style="width:32px; height:32px;" title="{{ user_details.full_name }}">
|
||||
|
||||
Reference in New Issue
Block a user