mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-13 11:55:11 +00:00
Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-46223
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.utils import cint, cstr
|
||||
from frappe.utils import add_to_date, cint, cstr, pretty_date
|
||||
from frappe.utils.nestedset import NestedSet, get_ancestors_of, get_descendants_of
|
||||
|
||||
import erpnext
|
||||
@@ -481,6 +481,7 @@ def get_account_autoname(account_number, account_name, company):
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_account_number(name, account_name, account_number=None, from_descendant=False):
|
||||
_ensure_idle_system()
|
||||
account = frappe.get_cached_doc("Account", name)
|
||||
if not account:
|
||||
return
|
||||
@@ -542,6 +543,7 @@ def update_account_number(name, account_name, account_number=None, from_descenda
|
||||
|
||||
@frappe.whitelist()
|
||||
def merge_account(old, new):
|
||||
_ensure_idle_system()
|
||||
# Validate properties before merging
|
||||
new_account = frappe.get_cached_doc("Account", new)
|
||||
old_account = frappe.get_cached_doc("Account", old)
|
||||
@@ -595,3 +597,27 @@ def sync_update_account_number_in_child(
|
||||
|
||||
for d in frappe.db.get_values("Account", filters=filters, fieldname=["company", "name"], as_dict=True):
|
||||
update_account_number(d["name"], account_name, account_number, from_descendant=True)
|
||||
|
||||
|
||||
def _ensure_idle_system():
|
||||
# Don't allow renaming if accounting entries are actively being updated, there are two main reasons:
|
||||
# 1. Correctness: It's next to impossible to ensure that renamed account is not being used *right now*.
|
||||
# 2. Performance: Renaming requires locking out many tables entirely and severely degrades performance.
|
||||
|
||||
if frappe.flags.in_test:
|
||||
return
|
||||
|
||||
try:
|
||||
# We also lock inserts to GL entry table with for_update here.
|
||||
last_gl_update = frappe.db.get_value("GL Entry", {}, "modified", for_update=True, wait=False)
|
||||
except frappe.QueryTimeoutError:
|
||||
# wait=False fails immediately if there's an active transaction.
|
||||
last_gl_update = add_to_date(None, seconds=-1)
|
||||
|
||||
if last_gl_update > add_to_date(None, minutes=-5):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Last GL Entry update was done {}. This operation is not allowed while system is actively being used. Please wait for 5 minutes before retrying."
|
||||
).format(pretty_date(last_gl_update)),
|
||||
title=_("System In Use"),
|
||||
)
|
||||
|
||||
@@ -1843,7 +1843,7 @@ class PaymentEntry(AccountsController):
|
||||
|
||||
allocated_positive_outstanding = paid_amount + allocated_negative_outstanding
|
||||
|
||||
elif self.party_type in ("Supplier", "Employee"):
|
||||
elif self.party_type in ("Supplier", "Customer"):
|
||||
if paid_amount > total_negative_outstanding:
|
||||
if total_negative_outstanding == 0:
|
||||
frappe.msgprint(
|
||||
@@ -3337,13 +3337,14 @@ def add_income_discount_loss(pe, doc, total_discount_percent) -> float:
|
||||
"""Add loss on income discount in base currency."""
|
||||
precision = doc.precision("total")
|
||||
base_loss_on_income = doc.get("base_total") * (total_discount_percent / 100)
|
||||
positive_negative = -1 if pe.payment_type == "Pay" else 1
|
||||
|
||||
pe.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": frappe.get_cached_value("Company", pe.company, "default_discount_account"),
|
||||
"cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"),
|
||||
"amount": flt(base_loss_on_income, precision),
|
||||
"amount": flt(base_loss_on_income, precision) * positive_negative,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3355,6 +3356,7 @@ def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float:
|
||||
tax_discount_loss = {}
|
||||
base_total_tax_loss = 0
|
||||
precision = doc.precision("tax_amount_after_discount_amount", "taxes")
|
||||
positive_negative = -1 if pe.payment_type == "Pay" else 1
|
||||
|
||||
# The same account head could be used more than once
|
||||
for tax in doc.get("taxes", []):
|
||||
@@ -3377,7 +3379,7 @@ def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float:
|
||||
"account": account,
|
||||
"cost_center": pe.cost_center
|
||||
or frappe.get_cached_value("Company", pe.company, "cost_center"),
|
||||
"amount": flt(loss, precision),
|
||||
"amount": flt(loss, precision) * positive_negative,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -282,6 +282,48 @@ class TestPaymentEntry(FrappeTestCase):
|
||||
self.assertEqual(si.payment_schedule[0].paid_amount, 200.0)
|
||||
self.assertEqual(si.payment_schedule[1].paid_amount, 36.0)
|
||||
|
||||
def test_payment_entry_against_payment_terms_with_discount_on_pi(self):
|
||||
pi = make_purchase_invoice(do_not_save=1)
|
||||
create_payment_terms_template_with_discount()
|
||||
pi.payment_terms_template = "Test Discount Template"
|
||||
|
||||
frappe.db.set_value("Company", pi.company, "default_discount_account", "Write Off - _TC")
|
||||
|
||||
pi.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Service Tax",
|
||||
"rate": 18,
|
||||
},
|
||||
)
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 1)
|
||||
pe_with_tax_loss = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
|
||||
|
||||
self.assertEqual(pe_with_tax_loss.references[0].payment_term, "30 Credit Days with 10% Discount")
|
||||
self.assertEqual(pe_with_tax_loss.payment_type, "Pay")
|
||||
self.assertEqual(pe_with_tax_loss.references[0].allocated_amount, 295.0)
|
||||
self.assertEqual(pe_with_tax_loss.paid_amount, 265.5)
|
||||
self.assertEqual(pe_with_tax_loss.difference_amount, 0)
|
||||
self.assertEqual(pe_with_tax_loss.deductions[0].amount, -25.0) # Loss on Income
|
||||
self.assertEqual(pe_with_tax_loss.deductions[1].amount, -4.5) # Loss on Tax
|
||||
self.assertEqual(pe_with_tax_loss.deductions[1].account, "_Test Account Service Tax - _TC")
|
||||
|
||||
frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 0)
|
||||
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
|
||||
|
||||
self.assertEqual(pe.references[0].payment_term, "30 Credit Days with 10% Discount")
|
||||
self.assertEqual(pe.payment_type, "Pay")
|
||||
self.assertEqual(pe.references[0].allocated_amount, 295.0)
|
||||
self.assertEqual(pe.paid_amount, 265.5)
|
||||
self.assertEqual(pe.deductions[0].amount, -29.5)
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
|
||||
def test_payment_entry_against_payment_terms_with_discount(self):
|
||||
si = create_sales_invoice(do_not_save=1, qty=1, rate=200)
|
||||
create_payment_terms_template_with_discount()
|
||||
|
||||
@@ -1623,6 +1623,5 @@
|
||||
"states": [],
|
||||
"timeline_field": "customer",
|
||||
"title_field": "title",
|
||||
"track_changes": 1,
|
||||
"track_seen": 1
|
||||
}
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -13,17 +13,15 @@
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Voucher Type",
|
||||
"options": "DocType"
|
||||
"label": "Voucher Type"
|
||||
},
|
||||
{
|
||||
"fieldname": "voucher_name",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Voucher Name",
|
||||
"options": "voucher_type"
|
||||
"label": "Voucher Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "taxable_amount",
|
||||
@@ -36,7 +34,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-01-13 13:40:41.479208",
|
||||
"modified": "2025-02-05 16:39:14.863698",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Tax Withheld Vouchers",
|
||||
|
||||
@@ -18,8 +18,8 @@ class TaxWithheldVouchers(Document):
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
taxable_amount: DF.Currency
|
||||
voucher_name: DF.DynamicLink | None
|
||||
voucher_type: DF.Link | None
|
||||
voucher_name: DF.Data | None
|
||||
voucher_type: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -436,6 +436,7 @@ def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"):
|
||||
tax_details.get("tax_withholding_category"),
|
||||
company,
|
||||
),
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
for d in journal_entries_details:
|
||||
|
||||
@@ -38,6 +38,23 @@ class TestAccountsPayable(AccountsTestMixin, FrappeTestCase):
|
||||
self.assertEqual(data[1][0].get("outstanding"), 300)
|
||||
self.assertEqual(data[1][0].get("currency"), "USD")
|
||||
|
||||
def test_account_payable_for_debit_note(self):
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.is_return = 1
|
||||
pi.items[0].qty = -1
|
||||
pi = pi.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"party": [self.supplier],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
data = execute(filters)
|
||||
self.assertEqual(data[1][0].get("invoiced"), 300)
|
||||
|
||||
def create_purchase_invoice(self, do_not_submit=False):
|
||||
frappe.set_user("Administrator")
|
||||
pi = make_purchase_invoice(
|
||||
|
||||
@@ -267,6 +267,18 @@ class ReceivablePayableReport:
|
||||
row.invoiced_in_account_currency += amount_in_account_currency
|
||||
else:
|
||||
if self.is_invoice(ple):
|
||||
# when invoice has is_return marked
|
||||
if self.invoice_details.get(row.voucher_no, {}).get("is_return"):
|
||||
# for Credit Note
|
||||
if row.voucher_type == "Sales Invoice":
|
||||
row.credit_note -= amount
|
||||
row.credit_note_in_account_currency -= amount_in_account_currency
|
||||
# for Debit Note
|
||||
else:
|
||||
row.invoiced -= amount
|
||||
row.invoiced_in_account_currency -= amount_in_account_currency
|
||||
return
|
||||
|
||||
if row.voucher_no == ple.voucher_no == ple.against_voucher_no:
|
||||
row.paid -= amount
|
||||
row.paid_in_account_currency -= amount_in_account_currency
|
||||
@@ -421,7 +433,7 @@ class ReceivablePayableReport:
|
||||
# nosemgrep
|
||||
si_list = frappe.db.sql(
|
||||
"""
|
||||
select name, due_date, po_no
|
||||
select name, due_date, po_no, is_return
|
||||
from `tabSales Invoice`
|
||||
where posting_date <= %s
|
||||
and company = %s
|
||||
@@ -453,7 +465,7 @@ class ReceivablePayableReport:
|
||||
# nosemgrep
|
||||
for pi in frappe.db.sql(
|
||||
"""
|
||||
select name, due_date, bill_no, bill_date
|
||||
select name, due_date, bill_no, bill_date, is_return
|
||||
from `tabPurchase Invoice`
|
||||
where
|
||||
posting_date <= %s
|
||||
|
||||
@@ -204,7 +204,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
|
||||
expected_data_after_credit_note = [
|
||||
[100.0, 100.0, 40.0, 0.0, 60.0, si.name],
|
||||
[0, 0, 100.0, 0.0, -100.0, cr_note.name],
|
||||
[0, 0, 0, 100.0, -100.0, cr_note.name],
|
||||
]
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
si_row = next(
|
||||
@@ -478,13 +478,19 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 2)
|
||||
|
||||
expected_data = {sr.name: [10.0, -10.0, 0.0, -10], si.name: [100.0, 100.0, 10.0, 90.0]}
|
||||
expected_data = {sr.name: [0.0, 10.0, -10.0, 0.0, -10], si.name: [100.0, 0.0, 100.0, 10.0, 90.0]}
|
||||
|
||||
rows = report[:2]
|
||||
for row in rows:
|
||||
self.assertEqual(
|
||||
expected_data[row.voucher_no],
|
||||
[row.invoiced or row.paid, row.outstanding, row.remaining_balance, row.future_amount],
|
||||
[
|
||||
row.invoiced or row.paid,
|
||||
row.credit_note,
|
||||
row.outstanding,
|
||||
row.remaining_balance,
|
||||
row.future_amount,
|
||||
],
|
||||
)
|
||||
|
||||
pe.cancel()
|
||||
|
||||
@@ -27,6 +27,7 @@ def get_report_filters(report_filters):
|
||||
["Purchase Invoice", "docstatus", "=", 1],
|
||||
["Purchase Invoice", "per_received", "<", 100],
|
||||
["Purchase Invoice", "update_stock", "=", 0],
|
||||
["Purchase Invoice", "is_opening", "!=", "Yes"],
|
||||
]
|
||||
|
||||
if report_filters.get("purchase_invoice"):
|
||||
|
||||
@@ -263,6 +263,7 @@ def get_actual_details(name, filters):
|
||||
and ba.account=gl.account
|
||||
and b.{budget_against} = gl.{budget_against}
|
||||
and gl.fiscal_year between %s and %s
|
||||
and gl.is_cancelled = 0
|
||||
and b.{budget_against} = %s
|
||||
and exists(
|
||||
select
|
||||
|
||||
@@ -19,6 +19,10 @@ frappe.query_reports["Purchase Order Analysis"] = {
|
||||
width: "80",
|
||||
reqd: 1,
|
||||
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
|
||||
on_change: (report) => {
|
||||
report.set_filter_value("name", []);
|
||||
report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "to_date",
|
||||
@@ -27,6 +31,10 @@ frappe.query_reports["Purchase Order Analysis"] = {
|
||||
width: "80",
|
||||
reqd: 1,
|
||||
default: frappe.datetime.get_today(),
|
||||
on_change: (report) => {
|
||||
report.set_filter_value("name", []);
|
||||
report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "project",
|
||||
@@ -38,13 +46,17 @@ frappe.query_reports["Purchase Order Analysis"] = {
|
||||
{
|
||||
fieldname: "name",
|
||||
label: __("Purchase Order"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
width: "80",
|
||||
options: "Purchase Order",
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: { docstatus: 1 },
|
||||
};
|
||||
get_data: function (txt) {
|
||||
let filters = { docstatus: 1 };
|
||||
|
||||
const from_date = frappe.query_report.get_filter_value("from_date");
|
||||
const to_date = frappe.query_report.get_filter_value("to_date");
|
||||
if (from_date && to_date) filters["transaction_date"] = ["between", [from_date, to_date]];
|
||||
|
||||
return frappe.db.get_link_options("Purchase Order", txt, filters);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -52,9 +64,16 @@ frappe.query_reports["Purchase Order Analysis"] = {
|
||||
label: __("Status"),
|
||||
fieldtype: "MultiSelectList",
|
||||
width: "80",
|
||||
options: ["To Pay", "To Bill", "To Receive", "To Receive and Bill", "Completed"],
|
||||
options: ["To Pay", "To Bill", "To Receive", "To Receive and Bill", "Completed", "Closed"],
|
||||
get_data: function (txt) {
|
||||
let status = ["To Bill", "To Receive", "To Receive and Bill", "Completed"];
|
||||
let status = [
|
||||
"To Pay",
|
||||
"To Bill",
|
||||
"To Receive",
|
||||
"To Receive and Bill",
|
||||
"Completed",
|
||||
"Closed",
|
||||
];
|
||||
let options = [];
|
||||
for (let option of status) {
|
||||
options.push({
|
||||
|
||||
@@ -70,14 +70,16 @@ def get_data(filters):
|
||||
po.company,
|
||||
po_item.name,
|
||||
)
|
||||
.where((po_item.parent == po.name) & (po.status.notin(("Stopped", "Closed"))) & (po.docstatus == 1))
|
||||
.where((po_item.parent == po.name) & (po.status.notin(("Stopped", "On Hold"))) & (po.docstatus == 1))
|
||||
.groupby(po_item.name)
|
||||
.orderby(po.transaction_date)
|
||||
)
|
||||
|
||||
for field in ("company", "name"):
|
||||
if filters.get(field):
|
||||
query = query.where(po[field] == filters.get(field))
|
||||
if filters.get("company"):
|
||||
query = query.where(po.company == filters.get("company"))
|
||||
|
||||
if filters.get("name"):
|
||||
query = query.where(po.name.isin(filters.get("name")))
|
||||
|
||||
if filters.get("from_date") and filters.get("to_date"):
|
||||
query = query.where(po.transaction_date.between(filters.get("from_date"), filters.get("to_date")))
|
||||
|
||||
@@ -194,6 +194,14 @@ class AccountsController(TransactionBase):
|
||||
self.set_incoming_rate()
|
||||
self.init_internal_values()
|
||||
|
||||
# Need to set taxes based on taxes_and_charges template
|
||||
# before calculating taxes and totals
|
||||
if self.meta.get_field("taxes_and_charges"):
|
||||
self.validate_enabled_taxes_and_charges()
|
||||
self.validate_tax_account_company()
|
||||
|
||||
self.set_taxes_and_charges()
|
||||
|
||||
if self.meta.get_field("currency"):
|
||||
self.calculate_taxes_and_totals()
|
||||
|
||||
@@ -204,10 +212,6 @@ class AccountsController(TransactionBase):
|
||||
|
||||
self.validate_all_documents_schedule()
|
||||
|
||||
if self.meta.get_field("taxes_and_charges"):
|
||||
self.validate_enabled_taxes_and_charges()
|
||||
self.validate_tax_account_company()
|
||||
|
||||
self.validate_party()
|
||||
self.validate_currency()
|
||||
self.validate_party_account_currency()
|
||||
@@ -252,8 +256,6 @@ class AccountsController(TransactionBase):
|
||||
self.validate_deferred_income_expense_account()
|
||||
self.set_inter_company_account()
|
||||
|
||||
self.set_taxes_and_charges()
|
||||
|
||||
if self.doctype == "Purchase Invoice":
|
||||
self.calculate_paid_amount()
|
||||
# apply tax withholding only if checked and applicable
|
||||
@@ -821,11 +823,15 @@ class AccountsController(TransactionBase):
|
||||
and item.get("use_serial_batch_fields")
|
||||
)
|
||||
):
|
||||
if fieldname == "batch_no" and not item.batch_no and not item.is_free_item:
|
||||
item.set("rate", ret.get("rate"))
|
||||
item.set("price_list_rate", ret.get("price_list_rate"))
|
||||
item.set(fieldname, value)
|
||||
|
||||
if fieldname == "batch_no" and item.batch_no and not item.is_free_item:
|
||||
if ret.get("rate"):
|
||||
item.set("rate", ret.get("rate"))
|
||||
|
||||
if not item.get("price_list_rate") and ret.get("price_list_rate"):
|
||||
item.set("price_list_rate", ret.get("price_list_rate"))
|
||||
|
||||
elif fieldname in ["cost_center", "conversion_factor"] and not item.get(
|
||||
fieldname
|
||||
):
|
||||
|
||||
@@ -931,6 +931,35 @@ class TestAccountsController(FrappeTestCase):
|
||||
self.assertEqual(exc_je_for_si, [])
|
||||
self.assertEqual(exc_je_for_pe, [])
|
||||
|
||||
@change_settings("Accounts Settings", {"add_taxes_from_item_tax_template": 1})
|
||||
def test_18_fetch_taxes_based_on_taxes_and_charges_template(self):
|
||||
# Create a Sales Taxes and Charges Template
|
||||
if not frappe.db.exists("Sales Taxes and Charges Template", "_Test Tax - _TC"):
|
||||
doc = frappe.new_doc("Sales Taxes and Charges Template")
|
||||
doc.company = self.company
|
||||
doc.title = "_Test Tax"
|
||||
doc.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "Sales Expenses - _TC",
|
||||
"description": "Test taxes",
|
||||
"rate": 9,
|
||||
},
|
||||
)
|
||||
doc.insert()
|
||||
|
||||
# Create a Sales Invoice
|
||||
sinv = frappe.new_doc("Sales Invoice")
|
||||
sinv.customer = self.customer
|
||||
sinv.company = self.company
|
||||
sinv.currency = "INR"
|
||||
sinv.taxes_and_charges = "_Test Tax - _TC"
|
||||
sinv.append("items", {"item_code": "_Test Item", "qty": 1, "rate": 50})
|
||||
sinv.insert()
|
||||
|
||||
self.assertEqual(sinv.total_taxes_and_charges, 4.5)
|
||||
|
||||
def test_20_journal_against_sales_invoice(self):
|
||||
# Invoice in Foreign Currency
|
||||
si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1)
|
||||
|
||||
@@ -4,7 +4,7 @@ app_publisher = "Frappe Technologies Pvt. Ltd."
|
||||
app_description = """ERP made simple"""
|
||||
app_icon = "fa fa-th"
|
||||
app_color = "#e74c3c"
|
||||
app_email = "info@erpnext.com"
|
||||
app_email = "hello@frappe.io"
|
||||
app_license = "GNU General Public License (v3)"
|
||||
source_link = "https://github.com/frappe/erpnext"
|
||||
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
|
||||
@@ -484,7 +484,7 @@ email_brand_image = "assets/erpnext/images/erpnext-logo.jpg"
|
||||
default_mail_footer = """
|
||||
<span>
|
||||
Sent via
|
||||
<a class="text-muted" href="https://erpnext.com?source=via_email_footer" target="_blank">
|
||||
<a class="text-muted" href="https://frappe.io/erpnext?source=via_email_footer" target="_blank">
|
||||
ERPNext
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -28,6 +28,8 @@ BOM_ITEM_FIELDS = [
|
||||
"stock_uom",
|
||||
"conversion_factor",
|
||||
"do_not_explode",
|
||||
"source_warehouse",
|
||||
"allow_alternative_item",
|
||||
]
|
||||
|
||||
|
||||
@@ -291,7 +293,6 @@ class BOMCreator(Document):
|
||||
"item": row.item_code,
|
||||
"bom_type": "Production",
|
||||
"quantity": row.qty,
|
||||
"allow_alternative_item": 1,
|
||||
"bom_creator": self.name,
|
||||
"bom_creator_item": bom_creator_item,
|
||||
}
|
||||
@@ -315,7 +316,6 @@ class BOMCreator(Document):
|
||||
item_args.update(
|
||||
{
|
||||
"bom_no": bom_no,
|
||||
"allow_alternative_item": 1,
|
||||
"allow_scrap_items": 1,
|
||||
"include_item_in_manufacturing": 1,
|
||||
}
|
||||
@@ -428,6 +428,7 @@ def add_sub_assembly(**kwargs):
|
||||
"do_not_explode": 1,
|
||||
"is_expandable": 1,
|
||||
"stock_uom": item_info.stock_uom,
|
||||
"allow_alternative_item": kwargs.allow_alternative_item,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"is_expandable",
|
||||
"sourced_by_supplier",
|
||||
"bom_created",
|
||||
"allow_alternative_item",
|
||||
"description_section",
|
||||
"description",
|
||||
"quantity_and_rate_section",
|
||||
@@ -225,12 +226,18 @@
|
||||
"label": "BOM Created",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "allow_alternative_item",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Alternative Item"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-06-03 18:45:24.339532",
|
||||
"modified": "2025-02-19 13:25:15.732496",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Creator Item",
|
||||
|
||||
@@ -14,6 +14,7 @@ class BOMCreatorItem(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
allow_alternative_item: DF.Check
|
||||
amount: DF.Currency
|
||||
base_amount: DF.Currency
|
||||
base_rate: DF.Currency
|
||||
|
||||
@@ -394,3 +394,4 @@ execute:frappe.db.set_single_value("Accounts Settings", "exchange_gain_loss_post
|
||||
erpnext.patches.v14_0.disable_add_row_in_gross_profit
|
||||
erpnext.patches.v15_0.set_difference_amount_in_asset_value_adjustment
|
||||
erpnext.patches.v14_0.update_posting_datetime
|
||||
erpnext.stock.doctype.stock_ledger_entry.patches.ensure_sle_indexes
|
||||
|
||||
@@ -82,6 +82,9 @@ def get_asset_depreciation_schedules_map():
|
||||
.orderby(ds.idx)
|
||||
).run(as_dict=True)
|
||||
|
||||
if len(records) > 20000:
|
||||
frappe.db.auto_commit_on_many_writes = True
|
||||
|
||||
asset_depreciation_schedules_map = frappe._dict()
|
||||
for d in records:
|
||||
asset_depreciation_schedules_map.setdefault((d.asset_name, cstr(d.finance_book)), []).append(d)
|
||||
|
||||
@@ -210,6 +210,13 @@ class BOMConfigurator {
|
||||
[
|
||||
{ label: __("Item"), fieldname: "item_code", fieldtype: "Link", options: "Item", reqd: 1 },
|
||||
{ label: __("Qty"), fieldname: "qty", default: 1.0, fieldtype: "Float", reqd: 1 },
|
||||
{
|
||||
label: __("Allow Alternative Item"),
|
||||
fieldname: "allow_alternative_item",
|
||||
default: 1.0,
|
||||
fieldtype: "Check",
|
||||
reqd: 1,
|
||||
},
|
||||
],
|
||||
(data) => {
|
||||
if (!node.data.parent_id) {
|
||||
@@ -224,6 +231,7 @@ class BOMConfigurator {
|
||||
item_code: data.item_code,
|
||||
fg_reference_id: node.data.name || this.frm.doc.name,
|
||||
qty: data.qty,
|
||||
allow_alternative_item: data.allow_alternative_item,
|
||||
},
|
||||
callback: (r) => {
|
||||
view.events.load_tree(r, node);
|
||||
@@ -258,6 +266,7 @@ class BOMConfigurator {
|
||||
fg_item: node.data.value,
|
||||
fg_reference_id: node.data.name || this.frm.doc.name,
|
||||
bom_item: bom_item,
|
||||
allow_alternative_item: bom_item.allow_alternative_item,
|
||||
},
|
||||
callback: (r) => {
|
||||
view.events.load_tree(r, node);
|
||||
@@ -278,6 +287,14 @@ class BOMConfigurator {
|
||||
reqd: 1,
|
||||
read_only: read_only,
|
||||
},
|
||||
{
|
||||
label: __("Allow Alternative Item"),
|
||||
fieldname: "allow_alternative_item",
|
||||
default: 1.0,
|
||||
fieldtype: "Check",
|
||||
reqd: 1,
|
||||
read_only: read_only,
|
||||
},
|
||||
{ fieldtype: "Column Break" },
|
||||
{
|
||||
label: __("Qty"),
|
||||
|
||||
@@ -14,6 +14,10 @@ erpnext.landed_cost_taxes_and_charges = {
|
||||
"Income Account",
|
||||
"Expenses Included In Valuation",
|
||||
"Expenses Included In Asset Valuation",
|
||||
"Expense Account",
|
||||
"Direct Expense",
|
||||
"Indirect Expense",
|
||||
"Stock Received But Not Billed",
|
||||
],
|
||||
],
|
||||
company: frm.doc.company,
|
||||
|
||||
@@ -540,6 +540,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
has_batch_no: this.item.has_batch_no,
|
||||
qty: qty,
|
||||
based_on: based_on,
|
||||
posting_date: this.frm.doc.posting_date,
|
||||
posting_time: this.frm.doc.posting_time,
|
||||
},
|
||||
callback: (r) => {
|
||||
if (r.message) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{ address_line1 }}<br>
|
||||
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
|
||||
{{ city }}, {% if state %}{{ state }}{% endif -%}{% if pincode %} {{ pincode }}<br>{% endif -%}
|
||||
{{ city }}, {% if state %}{{ state }}{% endif -%}{% if pincode %} {{ pincode }}{% endif -%}<br>
|
||||
{% if country != "United States" %}{{ country }}{% endif -%}
|
||||
|
||||
@@ -2097,6 +2097,45 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
frappe.db.set_single_value("Stock Settings", "update_existing_price_list_rate", 0)
|
||||
frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0)
|
||||
|
||||
def test_delivery_note_rate_on_change_of_warehouse(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
item = make_item(
|
||||
"_Test Batch Item for Delivery Note Rate",
|
||||
{
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "BH-SDDTBIFRM-.#####",
|
||||
},
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 1)
|
||||
so = make_sales_order(
|
||||
item_code=item.name, rate=27648.00, price_list_rate=27648.00, qty=1, do_not_submit=True
|
||||
)
|
||||
|
||||
so.items[0].rate = 90
|
||||
so.save()
|
||||
self.assertTrue(so.items[0].discount_amount == 27558.0)
|
||||
so.submit()
|
||||
|
||||
warehouse = create_warehouse("NW Warehouse FOR Rate", company=so.company)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=2,
|
||||
target=warehouse,
|
||||
basic_rate=100,
|
||||
company=so.company,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
|
||||
dn = make_delivery_note(so.name)
|
||||
dn.items[0].warehouse = warehouse
|
||||
dn.save()
|
||||
|
||||
self.assertEqual(dn.items[0].rate, 90)
|
||||
|
||||
def test_credit_limit_on_so_reopning(self):
|
||||
# set credit limit
|
||||
company = "_Test Company"
|
||||
|
||||
@@ -320,13 +320,13 @@ def get_past_order_list(search_term, status, limit=20):
|
||||
invoice_list = []
|
||||
|
||||
if search_term and status:
|
||||
invoices_by_customer = frappe.db.get_all(
|
||||
invoices_by_customer = frappe.db.get_list(
|
||||
"POS Invoice",
|
||||
filters={"customer": ["like", f"%{search_term}%"], "status": status},
|
||||
fields=fields,
|
||||
page_length=limit,
|
||||
)
|
||||
invoices_by_name = frappe.db.get_all(
|
||||
invoices_by_name = frappe.db.get_list(
|
||||
"POS Invoice",
|
||||
filters={"name": ["like", f"%{search_term}%"], "status": status},
|
||||
fields=fields,
|
||||
@@ -335,7 +335,7 @@ def get_past_order_list(search_term, status, limit=20):
|
||||
|
||||
invoice_list = invoices_by_customer + invoices_by_name
|
||||
elif status:
|
||||
invoice_list = frappe.db.get_all(
|
||||
invoice_list = frappe.db.get_list(
|
||||
"POS Invoice", filters={"status": status}, fields=fields, page_length=limit
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ frappe.query_reports["Sales Order Analysis"] = {
|
||||
width: "80",
|
||||
reqd: 1,
|
||||
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
|
||||
on_change: (report) => {
|
||||
report.set_filter_value("sales_order", []);
|
||||
report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "to_date",
|
||||
@@ -27,6 +31,10 @@ frappe.query_reports["Sales Order Analysis"] = {
|
||||
width: "80",
|
||||
reqd: 1,
|
||||
default: frappe.datetime.get_today(),
|
||||
on_change: (report) => {
|
||||
report.set_filter_value("sales_order", []);
|
||||
report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "sales_order",
|
||||
@@ -35,12 +43,13 @@ frappe.query_reports["Sales Order Analysis"] = {
|
||||
width: "80",
|
||||
options: "Sales Order",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Sales Order", txt);
|
||||
},
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: { docstatus: 1 },
|
||||
};
|
||||
let filters = { docstatus: 1 };
|
||||
|
||||
const from_date = frappe.query_report.get_filter_value("from_date");
|
||||
const to_date = frappe.query_report.get_filter_value("to_date");
|
||||
if (from_date && to_date) filters["transaction_date"] = ["between", [from_date, to_date]];
|
||||
|
||||
return frappe.db.get_link_options("Sales Order", txt, filters);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -53,10 +62,17 @@ frappe.query_reports["Sales Order Analysis"] = {
|
||||
fieldname: "status",
|
||||
label: __("Status"),
|
||||
fieldtype: "MultiSelectList",
|
||||
options: ["To Pay", "To Bill", "To Deliver", "To Deliver and Bill", "Completed"],
|
||||
options: ["To Pay", "To Bill", "To Deliver", "To Deliver and Bill", "Completed", "Closed"],
|
||||
width: "80",
|
||||
get_data: function (txt) {
|
||||
let status = ["To Bill", "To Deliver", "To Deliver and Bill", "Completed"];
|
||||
let status = [
|
||||
"To Pay",
|
||||
"To Bill",
|
||||
"To Deliver",
|
||||
"To Deliver and Bill",
|
||||
"Completed",
|
||||
"Closed",
|
||||
];
|
||||
let options = [];
|
||||
for (let option of status) {
|
||||
options.push({
|
||||
|
||||
@@ -86,7 +86,7 @@ def get_data(conditions, filters):
|
||||
ON sii.so_detail = soi.name and sii.docstatus = 1
|
||||
WHERE
|
||||
soi.parent = so.name
|
||||
and so.status not in ('Stopped', 'Closed', 'On Hold')
|
||||
and so.status not in ('Stopped', 'On Hold')
|
||||
and so.docstatus = 1
|
||||
{conditions}
|
||||
GROUP BY soi.name
|
||||
|
||||
@@ -16,7 +16,7 @@ from erpnext.setup.doctype.incoterm.incoterm import create_incoterms
|
||||
from .default_success_action import get_default_success_action
|
||||
|
||||
default_mail_footer = """<div style="padding: 7px; text-align: right; color: #888"><small>Sent via
|
||||
<a style="color: #888" href="http://erpnext.org">ERPNext</a></div>"""
|
||||
<a style="color: #888" href="http://frappe.io/erpnext">ERPNext</a></div>"""
|
||||
|
||||
|
||||
def after_install():
|
||||
|
||||
@@ -1048,15 +1048,19 @@ def get_billed_amount_against_po(po_items):
|
||||
if not po_items:
|
||||
return {}
|
||||
|
||||
purchase_invoice = frappe.qb.DocType("Purchase Invoice")
|
||||
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(purchase_invoice_item)
|
||||
.inner_join(purchase_invoice)
|
||||
.on(purchase_invoice_item.parent == purchase_invoice.name)
|
||||
.select(fn.Sum(purchase_invoice_item.amount).as_("billed_amt"), purchase_invoice_item.po_detail)
|
||||
.where(
|
||||
(purchase_invoice_item.po_detail.isin(po_items))
|
||||
& (purchase_invoice_item.docstatus == 1)
|
||||
& (purchase_invoice.docstatus == 1)
|
||||
& (purchase_invoice_item.pr_detail.isnull())
|
||||
& (purchase_invoice.update_stock == 0)
|
||||
)
|
||||
.groupby(purchase_invoice_item.po_detail)
|
||||
).run(as_dict=1)
|
||||
|
||||
@@ -4047,6 +4047,36 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
batch_return.save()
|
||||
batch_return.submit()
|
||||
|
||||
def test_pr_status_based_on_invoices_with_update_stock(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import (
|
||||
make_purchase_invoice as _make_purchase_invoice,
|
||||
)
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import (
|
||||
make_purchase_receipt as _make_purchase_receipt,
|
||||
)
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
|
||||
create_pr_against_po,
|
||||
create_purchase_order,
|
||||
)
|
||||
|
||||
item_code = "Test Item for PR Status Based on Invoices"
|
||||
create_item(item_code)
|
||||
|
||||
po = create_purchase_order(item_code=item_code, qty=10)
|
||||
pi = _make_purchase_invoice(po.name)
|
||||
pi.update_stock = 1
|
||||
pi.items[0].qty = 5
|
||||
pi.submit()
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(po.per_billed, 50)
|
||||
|
||||
pr = _make_purchase_receipt(po.name)
|
||||
self.assertEqual(pr.items[0].qty, 5)
|
||||
pr.submit()
|
||||
pr.reload()
|
||||
self.assertEqual(pr.status, "To Bill")
|
||||
|
||||
|
||||
def prepare_data_for_internal_transfer():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import (
|
||||
on_doctype_update as create_sle_indexes,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
"""Ensure SLE Indexes"""
|
||||
|
||||
create_sle_indexes()
|
||||
@@ -51,6 +51,10 @@ def format_report_data(filters: Filters, item_details: dict, to_date: str) -> li
|
||||
latest_age = date_diff(to_date, fifo_queue[-1][1])
|
||||
range_values = get_range_age(filters, fifo_queue, to_date, item_dict)
|
||||
|
||||
check_and_replace_valuations_if_moving_average(
|
||||
range_values, details.valuation_method, details.valuation_rate
|
||||
)
|
||||
|
||||
row = [details.name, details.item_name, details.description, details.item_group, details.brand]
|
||||
|
||||
if filters.get("show_warehouse_wise_stock"):
|
||||
@@ -72,6 +76,15 @@ def format_report_data(filters: Filters, item_details: dict, to_date: str) -> li
|
||||
return data
|
||||
|
||||
|
||||
def check_and_replace_valuations_if_moving_average(range_values, item_valuation_method, valuation_rate):
|
||||
if item_valuation_method == "Moving Average" or (
|
||||
not item_valuation_method
|
||||
and frappe.db.get_single_value("Stock Settings", "valuation_method") == "Moving Average"
|
||||
):
|
||||
for i in range(0, len(range_values), 2):
|
||||
range_values[i + 1] = range_values[i] * valuation_rate
|
||||
|
||||
|
||||
def get_average_age(fifo_queue: list, to_date: str) -> float:
|
||||
batch_age = age_qty = total_qty = 0.0
|
||||
for batch in fifo_queue:
|
||||
@@ -267,7 +280,7 @@ class FIFOSlots:
|
||||
|
||||
self.__update_balances(d, key)
|
||||
|
||||
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
|
||||
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
|
||||
del stock_ledger_entries
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
@@ -396,6 +409,7 @@ class FIFOSlots:
|
||||
self.item_details[key]["total_qty"] += row.actual_qty
|
||||
|
||||
self.item_details[key]["has_serial_no"] = row.has_serial_no
|
||||
self.item_details[key]["details"].valuation_rate = row.valuation_rate
|
||||
|
||||
def __aggregate_details_by_item(self, wh_wise_data: dict) -> dict:
|
||||
"Aggregate Item-Wh wise data into single Item entry."
|
||||
@@ -437,8 +451,10 @@ class FIFOSlots:
|
||||
item.description,
|
||||
item.stock_uom,
|
||||
item.has_serial_no,
|
||||
item.valuation_method,
|
||||
sle.actual_qty,
|
||||
sle.stock_value_difference,
|
||||
sle.valuation_rate,
|
||||
sle.posting_date,
|
||||
sle.voucher_type,
|
||||
sle.voucher_no,
|
||||
@@ -506,7 +522,14 @@ class FIFOSlots:
|
||||
item_table = frappe.qb.DocType("Item")
|
||||
|
||||
item = frappe.qb.from_("Item").select(
|
||||
"name", "item_name", "description", "stock_uom", "brand", "item_group", "has_serial_no"
|
||||
"name",
|
||||
"item_name",
|
||||
"description",
|
||||
"stock_uom",
|
||||
"brand",
|
||||
"item_group",
|
||||
"has_serial_no",
|
||||
"valuation_method",
|
||||
)
|
||||
|
||||
if self.filters.get("item_code"):
|
||||
|
||||
@@ -1006,6 +1006,10 @@ class SerialBatchCreation:
|
||||
elif self.has_serial_no and not self.get("serial_nos"):
|
||||
self.serial_nos = get_serial_nos_for_outward(kwargs)
|
||||
elif not self.has_serial_no and self.has_batch_no and not self.get("batches"):
|
||||
if self.get("posting_date"):
|
||||
kwargs["posting_date"] = self.get("posting_date")
|
||||
kwargs["posting_time"] = self.get("posting_time")
|
||||
|
||||
self.batches = get_available_batches(kwargs)
|
||||
|
||||
def set_auto_serial_batch_entries_for_inward(self):
|
||||
|
||||
@@ -561,12 +561,28 @@ class update_entries_after:
|
||||
self.new_items_found = False
|
||||
self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict())
|
||||
self.affected_transactions: set[tuple[str, str]] = set()
|
||||
self.reserved_stock = flt(self.args.reserved_stock)
|
||||
self.reserved_stock = self.get_reserved_stock()
|
||||
|
||||
self.data = frappe._dict()
|
||||
self.initialize_previous_data(self.args)
|
||||
self.build()
|
||||
|
||||
def get_reserved_stock(self):
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
posting_datetime = get_combine_datetime(self.args.posting_date, self.args.posting_time)
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.select(Sum(sre.reserved_qty) - Sum(sre.delivered_qty))
|
||||
.where(
|
||||
(sre.item_code == self.item_code)
|
||||
& (sre.warehouse == self.args.warehouse)
|
||||
& (sre.docstatus == 1)
|
||||
& (sre.creation <= posting_datetime)
|
||||
)
|
||||
).run()
|
||||
|
||||
return flt(query[0][0]) if query else 0.0
|
||||
|
||||
def set_precision(self):
|
||||
self.flt_precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
self.currency_precision = get_field_precision(
|
||||
|
||||
@@ -1 +1 @@
|
||||
{{ _("Powered by {0}").format('<a href="https://erpnext.com?source=website_footer" target="_blank" class="text-muted">ERPNext</a>') }}
|
||||
{{ _("Powered by {0}").format('<a href="https://frappe.io/erpnext?source=website_footer" target="_blank" class="text-muted">ERPNext</a>') }}
|
||||
|
||||
@@ -5098,7 +5098,7 @@ Percentage you are allowed to transfer more against the quantity ordered. For ex
|
||||
PUR-ORD-.YYYY.-,PUR-ORD-.YYYY.-,
|
||||
Get Items from Open Material Requests,Hole Artikel von offenen Material Anfragen,
|
||||
Fetch items based on Default Supplier.,Abrufen von Elementen basierend auf dem Standardlieferanten.,
|
||||
Required By,Benötigt von,
|
||||
Required By,Benötigt bis,
|
||||
Order Confirmation No,Auftragsbestätigung Nr,
|
||||
Order Confirmation Date,Auftragsbestätigungsdatum,
|
||||
Customer Mobile No,Mobilnummer des Kunden,
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5,7 +5,7 @@
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/frappe/erpnext.git"
|
||||
},
|
||||
"homepage": "https://erpnext.com",
|
||||
"homepage": "https://frappe.io/erpnext",
|
||||
"author": "Frappe Technologies Pvt. Ltd.",
|
||||
"license": "GPL-3.0",
|
||||
"bugs": {
|
||||
|
||||
@@ -70,6 +70,6 @@ docstring-code-format = true
|
||||
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://erpnext.com/"
|
||||
Homepage = "https://frappe.io/erpnext"
|
||||
Repository = "https://github.com/frappe/erpnext.git"
|
||||
"Bug Reports" = "https://github.com/frappe/erpnext/issues"
|
||||
|
||||
Reference in New Issue
Block a user