mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-04 17:10:21 +00:00
Compare commits
78 Commits
v15.120.0
...
version-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a0bf0bf6c | ||
|
|
57266ab474 | ||
|
|
865b207340 | ||
|
|
05199aa3bb | ||
|
|
4a4e1908e3 | ||
|
|
b439deda88 | ||
|
|
567730e38e | ||
|
|
3d563a2e08 | ||
|
|
08ec31174c | ||
|
|
c6fedf4aca | ||
|
|
547d82da56 | ||
|
|
d704dc474e | ||
|
|
46a99ec216 | ||
|
|
b98d193b77 | ||
|
|
5289982059 | ||
|
|
2f2a9301d8 | ||
|
|
0cef90d079 | ||
|
|
307ffcd5c3 | ||
|
|
d1b615b851 | ||
|
|
fa903f40fd | ||
|
|
c5bc5f9c31 | ||
|
|
28175a5c9d | ||
|
|
4cff4b7cec | ||
|
|
4ad0180dff | ||
|
|
eed5969c28 | ||
|
|
ae01c21614 | ||
|
|
7090c6fdab | ||
|
|
0f84b25964 | ||
|
|
0ea03a688b | ||
|
|
af8c010aa8 | ||
|
|
c656497aac | ||
|
|
9c19b024db | ||
|
|
7454735595 | ||
|
|
2d46bb962c | ||
|
|
6a632d6d1c | ||
|
|
e19686053f | ||
|
|
f0ac82605c | ||
|
|
2b2a005682 | ||
|
|
c4e7cfa6a0 | ||
|
|
1aa7528119 | ||
|
|
1a92047887 | ||
|
|
33ebc7d7e3 | ||
|
|
0609b06564 | ||
|
|
b8163e271d | ||
|
|
44cb0c5141 | ||
|
|
1187fb8e01 | ||
|
|
be0fc95758 | ||
|
|
3edd8ee8be | ||
|
|
d5318fbabc | ||
|
|
d87a4b2541 | ||
|
|
c6717fb758 | ||
|
|
0e7be81f86 | ||
|
|
5a518951e4 | ||
|
|
099a5a71d9 | ||
|
|
70dff35736 | ||
|
|
5851bb3b6e | ||
|
|
b00062df86 | ||
|
|
2ce0bb1009 | ||
|
|
25ec044c09 | ||
|
|
81cf874fd7 | ||
|
|
d62f6e715f | ||
|
|
045d7086d1 | ||
|
|
6f95c1ff92 | ||
|
|
17208e7434 | ||
|
|
59a9819dae | ||
|
|
32d44c6e91 | ||
|
|
605918d3a2 | ||
|
|
0dddd00073 | ||
|
|
3bf19df761 | ||
|
|
ccdaa92de8 | ||
|
|
6d59065d79 | ||
|
|
228ab2d97e | ||
|
|
e126c4597b | ||
|
|
22377fa86d | ||
|
|
de260bd43c | ||
|
|
9e082a96f7 | ||
|
|
bc071d7793 | ||
|
|
f5ce20f122 |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.120.0"
|
||||
__version__ = "15.121.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -105,25 +105,30 @@ class BankStatementImport(DataImport):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_preview_from_template(data_import, import_file=None, google_sheets_url=None):
|
||||
return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template(
|
||||
import_file, google_sheets_url
|
||||
)
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import)
|
||||
bsi.check_permission()
|
||||
return bsi.get_preview_from_template(import_file, google_sheets_url)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_import(data_import):
|
||||
return frappe.get_doc("Bank Statement Import", data_import).start_import()
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import)
|
||||
bsi.check_permission("write")
|
||||
return bsi.start_import()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_errored_template(data_import_name):
|
||||
data_import = frappe.get_doc("Bank Statement Import", data_import_name)
|
||||
data_import.check_permission()
|
||||
data_import.export_errored_rows()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_import_log(data_import_name):
|
||||
return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log()
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import_name)
|
||||
bsi.check_permission()
|
||||
return bsi.download_import_log()
|
||||
|
||||
|
||||
def parse_data_from_template(raw_data):
|
||||
|
||||
@@ -234,8 +234,10 @@ frappe.ui.form.on("Dunning", {
|
||||
dn: frm.doc.name,
|
||||
},
|
||||
callback: function (r) {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
if (!r.exc) {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1526,6 +1526,7 @@ def get_payment_entry_against_order(
|
||||
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
ref_doc.check_permission()
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||
@@ -1571,6 +1572,8 @@ def get_payment_entry_against_invoice(
|
||||
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
ref_doc.check_permission()
|
||||
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||
@@ -1606,6 +1609,8 @@ def get_payment_entry_against_invoice(
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args):
|
||||
frappe.has_permission("Journal Entry", ptype="create", throw=True)
|
||||
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
|
||||
@@ -56,7 +56,9 @@ class LedgerMerge(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_merge(docname):
|
||||
return frappe.get_doc("Ledger Merge", docname).start_merge()
|
||||
lm_doc = frappe.get_doc("Ledger Merge", docname)
|
||||
lm_doc.check_permission("write")
|
||||
return lm_doc.start_merge()
|
||||
|
||||
|
||||
def start_merge(docname):
|
||||
|
||||
@@ -282,6 +282,9 @@ def start_import(invoices):
|
||||
invoice_number = d.invoice_number
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
# the outstanding amount is entered inclusive of tax, so taxes must not
|
||||
# be added on top of it
|
||||
doc.flags.dont_auto_add_taxes = True
|
||||
doc.insert(set_name=invoice_number)
|
||||
doc.submit()
|
||||
frappe.db.commit()
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
|
||||
create_dimension,
|
||||
disable_dimension,
|
||||
@@ -12,6 +13,7 @@ from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension imp
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
|
||||
get_temporary_opening_account,
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
|
||||
test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
|
||||
@@ -140,6 +142,55 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
|
||||
for invoice in invoices:
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
|
||||
)
|
||||
def test_opening_invoice_creation_without_taxes(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Taxes and Charges Template",
|
||||
"company": company,
|
||||
"title": "_Test Opening Invoice Tax",
|
||||
"taxes": [
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": create_account(
|
||||
account_name="_Test Opening Tax Account",
|
||||
parent_account="Duties and Taxes - _TOIC",
|
||||
account_type="Tax",
|
||||
company=company,
|
||||
),
|
||||
"description": "Test taxes",
|
||||
"rate": 9,
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# makes the template the default for the party, as it would be on a live site
|
||||
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
|
||||
|
||||
tool = self.make_invoices(company=company, return_doc=True)
|
||||
invoices = tool.make_invoices()
|
||||
self.assertEqual(len(invoices), 2)
|
||||
|
||||
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
|
||||
for invoice in invoices:
|
||||
si = frappe.get_doc("Sales Invoice", invoice)
|
||||
self.assertFalse(si.taxes)
|
||||
self.assertEqual(si.grand_total, 200)
|
||||
self.assertEqual(si.outstanding_amount, 200)
|
||||
|
||||
# the same invoice created outside the tool keeps the default taxes,
|
||||
# since adding them there is the user's decision
|
||||
si = frappe.get_doc(tool.get_invoices()[0])
|
||||
si.flags.ignore_mandatory = True
|
||||
si.insert()
|
||||
self.assertTrue(si.taxes)
|
||||
self.assertEqual(si.grand_total, 218)
|
||||
|
||||
def test_opening_entry_project_linking(self):
|
||||
doc = self.make_invoices(
|
||||
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
|
||||
|
||||
@@ -2894,10 +2894,13 @@ def get_payment_entry(
|
||||
party_type=None,
|
||||
payment_type=None,
|
||||
reference_date=None,
|
||||
ignore_permissions=False,
|
||||
created_from_payment_request=False,
|
||||
):
|
||||
frappe.has_permission("Payment Entry", ptype="create", throw=True)
|
||||
|
||||
doc = frappe.get_doc(dt, dn)
|
||||
doc.check_permission()
|
||||
|
||||
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
|
||||
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance):
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt)))
|
||||
|
||||
@@ -83,6 +83,7 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
@frappe.whitelist()
|
||||
def make_payment_records(name, supplier, mode_of_payment=None):
|
||||
doc = frappe.get_doc("Payment Order", name)
|
||||
doc.check_permission()
|
||||
make_journal_entry(doc, supplier, mode_of_payment)
|
||||
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if for_cancellation and is_immutable_ledger_enabled():
|
||||
posting_date = getdate()
|
||||
|
||||
check_freezing_date(posting_date, self.company)
|
||||
check_freezing_date(posting_date)
|
||||
|
||||
def validate_start_and_end_date(self):
|
||||
self.fy_start_date, self.fy_end_date = frappe.db.get_value(
|
||||
|
||||
@@ -499,6 +499,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"depends_on": "customer",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -1571,7 +1572,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-22 04:18:50.691218",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
|
||||
@@ -271,40 +271,40 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
user = frappe.session["user"]
|
||||
company = filters.get("company") or frappe.defaults.get_user_default("company")
|
||||
|
||||
args = {
|
||||
"user": user,
|
||||
"start": start,
|
||||
"company": company,
|
||||
"page_len": page_len,
|
||||
"txt": "%%%s%%" % txt,
|
||||
}
|
||||
allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name")
|
||||
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf, `tabPOS Profile User` pfu
|
||||
where
|
||||
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
|
||||
and (pf.name like %(txt)s)
|
||||
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
|
||||
args,
|
||||
if not allowed_pos_profiles:
|
||||
return {}
|
||||
|
||||
pf = frappe.qb.DocType("POS Profile")
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.inner_join(pfu)
|
||||
.on(pfu.parent == pf.name)
|
||||
.select(pf.name)
|
||||
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
|
||||
.where(pf.name.isin(allowed_pos_profiles))
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
.run()
|
||||
)
|
||||
|
||||
if not pos_profile:
|
||||
del args["user"]
|
||||
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
|
||||
on
|
||||
pf.name = pfu.parent
|
||||
where
|
||||
ifnull(pfu.user, '') = ''
|
||||
and pf.company = %(company)s
|
||||
and pf.name like %(txt)s
|
||||
and pf.disabled = 0""",
|
||||
args,
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.left_join(pfu)
|
||||
.on(pf.name == pfu.parent)
|
||||
.select(pf.name)
|
||||
.where(
|
||||
(pfu.user.isnull() | (pfu.user == ""))
|
||||
& (pf.company == company)
|
||||
& pf.name.like(f"%{txt}%")
|
||||
& (pf.disabled == 0)
|
||||
& (pf.name.isin(allowed_pos_profiles))
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
return pos_profile
|
||||
|
||||
@@ -12,20 +12,22 @@
|
||||
{
|
||||
"fieldname": "fieldname",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Fieldname"
|
||||
"in_list_view": 1,
|
||||
"label": "Fieldname",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "field",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Field"
|
||||
"label": "Field",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-04-21 11:12:54.632093",
|
||||
"modified": "2026-08-31 20:41:12.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Search Fields",
|
||||
@@ -34,4 +36,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
let search_fields_datatypes = [
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
];
|
||||
let do_not_include_fields = [
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
];
|
||||
function is_valid_invoice_field(df) {
|
||||
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
|
||||
}
|
||||
|
||||
frappe.ui.form.on("POS Settings", {
|
||||
onload: function (frm) {
|
||||
@@ -44,57 +13,46 @@ frappe.ui.form.on("POS Settings", {
|
||||
|
||||
get_invoice_fields: function (frm) {
|
||||
frappe.model.with_doctype("POS Invoice", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
|
||||
if (
|
||||
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
|
||||
["Button"].includes(d.fieldtype)
|
||||
) {
|
||||
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const fields = frappe.get_doc("DocType", "POS Invoice").fields.filter(is_valid_invoice_field);
|
||||
|
||||
frm.fields_dict.invoice_fields.grid.update_docfield_property(
|
||||
"fieldname",
|
||||
"options",
|
||||
[""].concat(fields)
|
||||
[""].concat(
|
||||
fields.map((df) => {
|
||||
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
add_search_options: function (frm) {
|
||||
frappe.model.with_doctype("Item", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (
|
||||
search_fields_datatypes.includes(d.fieldtype) &&
|
||||
!do_not_include_fields.includes(d.fieldname)
|
||||
) {
|
||||
return [d.label];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
|
||||
callback: ({ message }) => {
|
||||
const fields = message || [];
|
||||
|
||||
fields.unshift("");
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
|
||||
frm.searchable_item_fields = Object.fromEntries(
|
||||
fields.map((df) => [df.option, df.fieldname])
|
||||
);
|
||||
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
|
||||
"field",
|
||||
"options",
|
||||
[""].concat(fields.map((df) => df.option))
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Search Fields", {
|
||||
field: function (frm, doctype, name) {
|
||||
var doc = frappe.get_doc(doctype, name);
|
||||
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
|
||||
return d;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})[0];
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
|
||||
doc.fieldname = df.fieldname;
|
||||
frm.refresh_field("fields");
|
||||
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
|
||||
frm.refresh_field("pos_search_fields");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,6 +68,6 @@ frappe.ui.form.on("POS Field", {
|
||||
doc.options = df.options;
|
||||
doc.fieldtype = df.fieldtype;
|
||||
doc.default_value = df.default;
|
||||
frm.refresh_field("fields");
|
||||
frm.refresh_field("invoice_fields");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,50 @@
|
||||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model import no_value_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
SEARCH_FIELD_TYPES = (
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
)
|
||||
|
||||
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
|
||||
DO_NOT_INCLUDE_FIELDS = (
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
)
|
||||
|
||||
|
||||
class POSSettings(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -22,4 +63,95 @@ class POSSettings(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
pass
|
||||
self.validate_duplicate_invoice_fields()
|
||||
self.validate_invoice_fields()
|
||||
self.validate_duplicate_pos_search_fields()
|
||||
self.validate_pos_search_fields()
|
||||
|
||||
def validate_duplicate_invoice_fields(self):
|
||||
fieldnames = [field.fieldname for field in self.invoice_fields]
|
||||
|
||||
for fieldname, count in Counter(fieldnames).items():
|
||||
if count > 1:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(fieldname)
|
||||
)
|
||||
|
||||
def validate_invoice_fields(self):
|
||||
# the POS screen only ever creates a POS Invoice
|
||||
meta = frappe.get_meta("POS Invoice")
|
||||
|
||||
for field in self.invoice_fields:
|
||||
df = meta.get_field(field.fieldname)
|
||||
|
||||
if not df or not is_valid_invoice_field(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Field"),
|
||||
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
|
||||
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_("POS Invoice"))
|
||||
),
|
||||
)
|
||||
|
||||
# read only in the form, so keep them in sync with the invoice
|
||||
field.label = df.label
|
||||
field.fieldtype = df.fieldtype
|
||||
field.options = df.options
|
||||
|
||||
def validate_duplicate_pos_search_fields(self):
|
||||
fieldnames = [field.fieldname for field in self.pos_search_fields]
|
||||
|
||||
for fieldname, count in Counter(fieldnames).items():
|
||||
if count > 1:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Search Fields"),
|
||||
msg=_("'{0}' has been already added.").format(fieldname),
|
||||
)
|
||||
|
||||
def validate_pos_search_fields(self):
|
||||
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
|
||||
|
||||
for field in self.pos_search_fields:
|
||||
df = searchable_fields.get(field.fieldname)
|
||||
|
||||
if not df:
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
|
||||
field.idx, frappe.bold(field.fieldname or "")
|
||||
),
|
||||
)
|
||||
|
||||
if field.field != get_search_field_option(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' does not match {2}.").format(
|
||||
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def is_valid_invoice_field(df):
|
||||
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
|
||||
|
||||
|
||||
def get_searchable_item_fields():
|
||||
return [
|
||||
df
|
||||
for df in frappe.get_meta("Item").fields
|
||||
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
|
||||
]
|
||||
|
||||
|
||||
def get_search_field_option(df):
|
||||
# the fieldname keeps the option unique, two Item fields can share a label
|
||||
return f"{df.label} ({df.fieldname})"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_pos_search_field_options():
|
||||
frappe.has_permission("POS Settings", throw=True)
|
||||
|
||||
return [
|
||||
{"option": get_search_field_option(df), "fieldname": df.fieldname}
|
||||
for df in get_searchable_item_fields()
|
||||
]
|
||||
|
||||
@@ -3,6 +3,119 @@
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
|
||||
|
||||
|
||||
class TestPOSSettings(unittest.TestCase):
|
||||
pass
|
||||
def setUp(self):
|
||||
self.settings = frappe.get_single("POS Settings")
|
||||
self.settings.invoice_fields = []
|
||||
self.settings.pos_search_fields = []
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def assertInvalid(self, message):
|
||||
with self.assertRaises(frappe.ValidationError) as context:
|
||||
self.settings.save()
|
||||
|
||||
self.assertIn(message, str(context.exception))
|
||||
|
||||
def test_duplicate_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "customer"})
|
||||
self.settings.append("invoice_fields", {"fieldname": "customer"})
|
||||
self.assertInvalid("'customer' has been already added.")
|
||||
|
||||
def test_unknown_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_layout_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_invoice_field_properties_are_set_from_the_invoice(self):
|
||||
self.settings.append(
|
||||
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
field = self.settings.invoice_fields[0]
|
||||
self.assertEqual(field.label, "Customer")
|
||||
self.assertEqual(field.fieldtype, "Link")
|
||||
self.assertEqual(field.options, "Customer")
|
||||
|
||||
def test_searchable_item_field_is_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
|
||||
|
||||
def test_excluded_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_of_unsearchable_type_is_not_allowed(self):
|
||||
# maintain stock is a Check field
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_unknown_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_without_a_fieldname_is_not_allowed(self):
|
||||
# the form fills the fieldname in, it cannot be picked on its own
|
||||
self.settings.append("pos_search_fields", {"field": "Description (description)"})
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_option_must_match_its_fieldname(self):
|
||||
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_bare_label_is_not_accepted_as_a_search_field(self):
|
||||
# the stored option carries the fieldname, the patch backfills older rows
|
||||
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_duplicate_search_fields_are_not_allowed(self):
|
||||
for _ in range(2):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
|
||||
self.assertInvalid("has been already added")
|
||||
|
||||
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
|
||||
|
||||
append_fieldname()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
def test_patch_leaves_an_already_migrated_search_field_alone(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
append_fieldname()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
@@ -139,6 +139,8 @@ def start_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def pause_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -154,6 +156,8 @@ def pause_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -168,6 +172,8 @@ def cancel_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def resume_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
|
||||
@@ -502,6 +502,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1663,7 +1664,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-05 15:40:16.519774",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -40,6 +40,7 @@ from erpnext.assets.doctype.asset_category.asset_category import get_asset_categ
|
||||
from erpnext.buying.utils import check_on_hold_or_closed_status
|
||||
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
get_item_account_wise_additional_cost,
|
||||
@@ -2201,6 +2202,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item")
|
||||
|
||||
def received_and_mapped_qty(obj):
|
||||
return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
|
||||
|
||||
def post_parent_process(source_parent, target_parent):
|
||||
remove_items_with_zero_qty(target_parent)
|
||||
set_missing_values(source_parent, target_parent)
|
||||
@@ -2215,13 +2221,13 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
target_parent.run_method("calculate_taxes_and_totals")
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
target.qty = flt(obj.qty) - flt(obj.received_qty)
|
||||
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
|
||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
||||
target.base_amount = (
|
||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
)
|
||||
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
|
||||
|
||||
target.qty = pending_qty
|
||||
target.received_qty = pending_qty
|
||||
target.stock_qty = pending_qty * flt(obj.conversion_factor)
|
||||
target.amount = pending_qty * flt(obj.rate)
|
||||
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -2251,7 +2257,8 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
"wip_composite_asset": "wip_composite_asset",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
||||
"condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty)
|
||||
and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -27,6 +27,7 @@ def start_payment_ledger_repost(docname=None):
|
||||
"""
|
||||
if docname:
|
||||
repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
|
||||
repost_doc.check_permission("submit")
|
||||
if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
|
||||
try:
|
||||
for entry in repost_doc.repost_vouchers:
|
||||
|
||||
@@ -597,6 +597,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"depends_on": "customer",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -2198,7 +2199,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-04-06 22:30:28.513139",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
|
||||
from erpnext.accounts.utils import get_currency_precision
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -43,6 +44,7 @@ def get_data(filters, show_party_name):
|
||||
account_filter = get_accounts_with_children(filters.get("account"))
|
||||
|
||||
company_currency = frappe.get_cached_value("Company", filters.company, "default_currency")
|
||||
precision = get_currency_precision()
|
||||
opening_balances = get_opening_balances(filters, account_filter)
|
||||
balances_within_period = get_balances_within_period(filters, account_filter)
|
||||
|
||||
@@ -65,14 +67,17 @@ def get_data(filters, show_party_name):
|
||||
|
||||
# opening
|
||||
opening_debit, opening_credit = opening_balances.get(party.name, [0, 0])
|
||||
opening_debit, opening_credit = flt(opening_debit, precision), flt(opening_credit, precision)
|
||||
row.update({"opening_debit": opening_debit, "opening_credit": opening_credit})
|
||||
|
||||
# within period
|
||||
debit, credit = balances_within_period.get(party.name, [0, 0])
|
||||
debit, credit = flt(debit, precision), flt(credit, precision)
|
||||
row.update({"debit": debit, "credit": credit})
|
||||
|
||||
# closing
|
||||
closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit)
|
||||
closing_debit, closing_credit = flt(closing_debit, precision), flt(closing_credit, precision)
|
||||
row.update({"closing_debit": closing_debit, "closing_credit": closing_credit})
|
||||
|
||||
row.update({"currency": company_currency})
|
||||
|
||||
@@ -402,6 +402,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1307,7 +1308,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-28 12:20:11.284370",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
|
||||
from erpnext.accounts.party import get_party_account, get_party_account_currency
|
||||
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
|
||||
validate_against_blanket_order,
|
||||
)
|
||||
@@ -737,13 +738,16 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
def is_unit_price_row(source):
|
||||
return has_unit_price_items and source.qty == 0
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_order_item")
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
|
||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
||||
target.base_amount = (
|
||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
)
|
||||
received_qty = flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
|
||||
pending_qty = flt(obj.qty) - received_qty
|
||||
|
||||
target.qty = flt(obj.qty) if is_unit_price_row(obj) else pending_qty
|
||||
target.stock_qty = pending_qty * flt(obj.conversion_factor)
|
||||
target.amount = pending_qty * flt(obj.rate)
|
||||
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -775,7 +779,9 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: (
|
||||
True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty)
|
||||
doc.name not in mapped_qty_by_item
|
||||
if is_unit_price_row(doc)
|
||||
else abs(doc.received_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty)
|
||||
)
|
||||
and doc.delivered_by_supplier != 1
|
||||
and select_item(doc),
|
||||
@@ -837,9 +843,13 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
|
||||
)
|
||||
return query.run(pluck="qty")[0] or 0
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "po_detail")
|
||||
|
||||
def get_billed_and_mapped_qty(po_item_name):
|
||||
return flt(get_billed_qty(po_item_name)) + flt(mapped_qty_by_item.get(po_item_name, 0))
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
billed_qty = flt(get_billed_qty(obj.name))
|
||||
target.qty = flt(obj.qty) - billed_qty
|
||||
target.qty = flt(obj.qty) - get_billed_and_mapped_qty(obj.name)
|
||||
|
||||
item = get_item_defaults(target.item_code, source_parent.company)
|
||||
item_group = get_item_group_defaults(target.item_code, source_parent.company)
|
||||
@@ -882,6 +892,7 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
|
||||
or abs(doc.billed_amt) < abs(doc.amount)
|
||||
or doc.qty > flt(get_billed_qty(doc.name))
|
||||
)
|
||||
and (doc.name not in mapped_qty_by_item or doc.qty > get_billed_and_mapped_qty(doc.name))
|
||||
and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
|
||||
|
||||
@@ -10,6 +10,7 @@ from frappe.contacts.address_and_contact import (
|
||||
load_address_and_contact,
|
||||
)
|
||||
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
|
||||
from frappe.utils import get_link_to_form
|
||||
|
||||
from erpnext.accounts.party import (
|
||||
get_dashboard_info,
|
||||
@@ -177,10 +178,15 @@ class Supplier(TransactionBase):
|
||||
)
|
||||
|
||||
if internal_supplier:
|
||||
internal_supplier_link = get_link_to_form("Supplier", internal_supplier)
|
||||
frappe.throw(
|
||||
_("Internal Supplier for company {0} already exists").format(
|
||||
frappe.bold(self.represents_company)
|
||||
)
|
||||
_(
|
||||
"Internal Supplier {0} already exists for {1}. Disable it to make this Supplier internal."
|
||||
).format(
|
||||
internal_supplier_link,
|
||||
frappe.bold(self.represents_company),
|
||||
),
|
||||
title=_("Internal Supplier Already Exists"),
|
||||
)
|
||||
|
||||
def create_primary_contact(self):
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -938,7 +939,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-03 17:39:38.459977",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation",
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.utils import flt, getdate, nowdate
|
||||
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
|
||||
@@ -243,6 +244,8 @@ def make_purchase_order(source_name, target_doc=None, args=None):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item")
|
||||
|
||||
def set_missing_values(source, target):
|
||||
target.run_method("set_missing_values")
|
||||
target.run_method("get_schedule_dates")
|
||||
@@ -277,7 +280,8 @@ def make_purchase_order(source_name, target_doc=None, args=None):
|
||||
["sales_order", "sales_order"],
|
||||
],
|
||||
"postprocess": update_item,
|
||||
"condition": select_item,
|
||||
# no qty tracking between the two, so dedupe on the row reference alone
|
||||
"condition": lambda d: d.name not in mapped_items and select_item(d),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -10,6 +10,26 @@ frappe.query_reports["Purchase Analytics"] = {
|
||||
options: ["Supplier Group", "Supplier", "Item Group", "Item"],
|
||||
default: "Supplier",
|
||||
reqd: 1,
|
||||
on_change: function () {
|
||||
const entity_filter = frappe.query_report.get_filter("entity");
|
||||
if (entity_filter) {
|
||||
entity_filter.df.label = __(frappe.query_report.get_filter_value("tree_type"));
|
||||
entity_filter.set_value([]);
|
||||
entity_filter.refresh();
|
||||
}
|
||||
frappe.query_report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "entity",
|
||||
label: __("Entity"),
|
||||
fieldtype: "MultiSelectList",
|
||||
get_data: function (txt) {
|
||||
const tree_type = frappe.query_report.get_filter_value("tree_type");
|
||||
if (!tree_type || tree_type === "Order Type") return [];
|
||||
return frappe.db.get_link_options(tree_type, txt);
|
||||
},
|
||||
depends_on: "eval:doc.tree_type != 'Order Type'",
|
||||
},
|
||||
{
|
||||
fieldname: "doc_type",
|
||||
@@ -65,6 +85,19 @@ frappe.query_reports["Purchase Analytics"] = {
|
||||
default: "Monthly",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "curves",
|
||||
label: __("Curves"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
{ value: "select", label: __("Select") },
|
||||
{ value: "all", label: __("All") },
|
||||
{ value: "non-zeros", label: __("Non-Zeros") },
|
||||
{ value: "total", label: __("Total Only") },
|
||||
],
|
||||
default: "select",
|
||||
reqd: 1,
|
||||
},
|
||||
],
|
||||
get_datatable_options(options) {
|
||||
return Object.assign(options, {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.buying.report.purchase_analytics.purchase_analytics import execute
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
SUPPLIER_GROUP = "_Test Supplier Group"
|
||||
# A historical window that ordinary test fixtures don't post into.
|
||||
FROM_DATE = "2019-04-01"
|
||||
TO_DATE = "2019-06-30"
|
||||
|
||||
|
||||
class TestPurchaseAnalytics(FrappeTestCase):
|
||||
"""purchase_analytics reuses the shared Analytics engine; these tests lock its
|
||||
wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def _filters(self, **overrides):
|
||||
filters = {
|
||||
"doc_type": "Purchase Order",
|
||||
"value_quantity": "Value",
|
||||
"range": "Monthly",
|
||||
"company": COMPANY,
|
||||
"from_date": FROM_DATE,
|
||||
"to_date": TO_DATE,
|
||||
}
|
||||
filters.update(overrides)
|
||||
return frappe._dict(filters)
|
||||
|
||||
def _rows(self, filters):
|
||||
return {row["entity"]: row for row in execute(filters)[1]}
|
||||
|
||||
def make_po(self, qty=4, rate=250):
|
||||
return create_purchase_order(
|
||||
company=COMPANY, supplier=SUPPLIER, qty=qty, rate=rate, transaction_date="2019-04-10"
|
||||
)
|
||||
|
||||
def test_supplier_entity_filter(self):
|
||||
filters = self._filters(tree_type="Supplier", entity=[SUPPLIER], curves="all")
|
||||
base_total = flt(self._rows(filters).get(SUPPLIER, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po()
|
||||
columns, data, _message, chart, *_rest = execute(filters)
|
||||
|
||||
self.assertTrue(columns)
|
||||
self.assertEqual({row["entity"] for row in data}, {SUPPLIER})
|
||||
self.assertAlmostEqual(data[0]["total"] - base_total, flt(po.base_net_total), places=2)
|
||||
|
||||
supplier_name = frappe.db.get_value("Supplier", SUPPLIER, "supplier_name")
|
||||
self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {supplier_name})
|
||||
|
||||
def test_parent_supplier_group_filter_preserves_rollup(self):
|
||||
self.make_po()
|
||||
filters = self._filters(tree_type="Supplier Group")
|
||||
unfiltered = self._rows(filters)
|
||||
filtered = self._rows(self._filters(tree_type="Supplier Group", entity=["All Supplier Groups"]))
|
||||
|
||||
self.assertEqual(set(filtered), {"All Supplier Groups"})
|
||||
self.assertAlmostEqual(
|
||||
filtered["All Supplier Groups"]["total"],
|
||||
unfiltered["All Supplier Groups"]["total"],
|
||||
places=2,
|
||||
)
|
||||
|
||||
def test_supplier_group_entity_filter(self):
|
||||
self.make_po()
|
||||
unfiltered = self._rows(self._filters(tree_type="Supplier Group"))
|
||||
filtered = self._rows(self._filters(tree_type="Supplier Group", entity=[SUPPLIER_GROUP]))
|
||||
|
||||
self.assertEqual(set(filtered), {SUPPLIER_GROUP})
|
||||
self.assertEqual(filtered[SUPPLIER_GROUP]["indent"], 0)
|
||||
self.assertAlmostEqual(
|
||||
filtered[SUPPLIER_GROUP]["total"], unfiltered[SUPPLIER_GROUP]["total"], places=2
|
||||
)
|
||||
|
||||
def test_supplier_group_tree_rolls_up_to_root(self):
|
||||
filters = self._filters(tree_type="Supplier Group")
|
||||
base = self._rows(filters)
|
||||
base_group = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=4, rate=250)
|
||||
rows = self._rows(filters)
|
||||
|
||||
# supplier is remapped to its group; the root sits at indent 0
|
||||
self.assertIn(SUPPLIER_GROUP, rows)
|
||||
self.assertIn("All Supplier Groups", rows)
|
||||
self.assertNotIn(SUPPLIER, rows)
|
||||
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
|
||||
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group, flt(po.base_net_total), places=2)
|
||||
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), flt(po.base_net_total))
|
||||
|
||||
def test_item_group_tree_rolls_up_to_root(self):
|
||||
item_group = frappe.db.get_value("Item", "_Test Item", "item_group")
|
||||
filters = self._filters(tree_type="Item Group")
|
||||
base = self._rows(filters)
|
||||
base_group = flt(base.get(item_group, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=4, rate=250)
|
||||
rows = self._rows(filters)
|
||||
|
||||
self.assertIn(item_group, rows)
|
||||
self.assertIn("All Item Groups", rows)
|
||||
# the raw item code must not leak as its own entity; the root sits at indent 0
|
||||
self.assertNotIn("_Test Item", rows)
|
||||
self.assertEqual(rows["All Item Groups"]["indent"], 0)
|
||||
self.assertAlmostEqual(rows[item_group]["total"] - base_group, flt(po.base_net_total), places=2)
|
||||
self.assertGreaterEqual(flt(rows["All Item Groups"]["total"]), flt(po.base_net_total))
|
||||
|
||||
def test_supplier_group_by_quantity(self):
|
||||
filters = self._filters(tree_type="Supplier Group", value_quantity="Quantity")
|
||||
base = self._rows(filters)
|
||||
base_qty = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
base_root_qty = flt(base.get("All Supplier Groups", {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=7, rate=100)
|
||||
rows = self._rows(filters)
|
||||
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_qty, flt(po.total_qty), places=2)
|
||||
# the quantity must roll up to the root too, not just the leaf group
|
||||
self.assertAlmostEqual(
|
||||
rows["All Supplier Groups"]["total"] - base_root_qty, flt(po.total_qty), places=2
|
||||
)
|
||||
@@ -1267,6 +1267,11 @@ class AccountsController(TransactionBase):
|
||||
if self.get("taxes") or self.get("is_pos"):
|
||||
return
|
||||
|
||||
# set by the Opening Invoice Creation Tool, where the outstanding amount
|
||||
# entered against a party is already inclusive of tax
|
||||
if self.flags.dont_auto_add_taxes:
|
||||
return
|
||||
|
||||
if frappe.get_single_value(
|
||||
"Accounts Settings", "add_taxes_from_taxes_and_charges_template"
|
||||
) and hasattr(self, "taxes_and_charges"):
|
||||
|
||||
25
erpnext/controllers/mapper.py
Normal file
25
erpnext/controllers/mapper.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
def get_qty_already_mapped(target_doc, ref_field: str, qty_field: str = "qty") -> frappe._dict:
|
||||
"""Return a map: {source row name: qty} of rows already mapped into the target document.
|
||||
|
||||
"Get Items From" passes the in-progress (unsaved) document back as `target_doc`. Its rows
|
||||
are invisible to the pending-qty queries in the mappers, which only count submitted
|
||||
documents -- so without this, selecting the same source document twice maps every row
|
||||
again. Rows are keyed by `ref_field` (dn_detail, so_detail, ...), and a row is present in
|
||||
the map even when its qty is 0, so mappers without qty tracking can dedupe on presence.
|
||||
"""
|
||||
if isinstance(target_doc, str):
|
||||
target_doc = frappe.parse_json(target_doc)
|
||||
|
||||
qty_map = frappe._dict()
|
||||
for row in (target_doc and target_doc.get("items")) or []:
|
||||
if ref := row.get(ref_field):
|
||||
qty_map[ref] = qty_map.get(ref, 0) + flt(row.get(qty_field))
|
||||
|
||||
return qty_map
|
||||
@@ -28,6 +28,96 @@ class TestMapper(unittest.TestCase):
|
||||
src_items = item_list_1 + item_list_2 + item_list_3
|
||||
self.assertEqual(set(d for d in src_items), set(d.item_code for d in updated_so.items))
|
||||
|
||||
def test_get_items_from_is_idempotent(self):
|
||||
"""Selecting the same source document twice must not duplicate rows in the target.
|
||||
|
||||
"Get Items From" hands the in-progress document back to the mapper as `target_doc`.
|
||||
Its rows are unsaved, so the mappers' pending-qty queries (submitted documents only)
|
||||
cannot see them -- every mapper has to discount them explicitly.
|
||||
"""
|
||||
for label, make_source, method in self.idempotency_cases():
|
||||
with self.subTest(label):
|
||||
source = make_source()
|
||||
target = frappe.get_attr(method)(source.name)
|
||||
mapped_rows = len(target.items)
|
||||
self.assertTrue(mapped_rows, f"{label}: nothing was mapped")
|
||||
|
||||
target = frappe.get_attr(method)(source.name, target)
|
||||
self.assertEqual(len(target.items), mapped_rows, f"{label}: rows were duplicated")
|
||||
|
||||
def idempotency_cases(self):
|
||||
"""(label, source factory, mapper method) for every "Get Items From" button.
|
||||
|
||||
Quotation -> Sales Invoice is absent: Sales Invoice Item keeps no reference to the
|
||||
Quotation row, so there is nothing to deduplicate on.
|
||||
"""
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.buying.doctype.supplier_quotation.test_supplier_quotation import (
|
||||
test_records as supplier_quotation_records,
|
||||
)
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
def make_supplier_quotation():
|
||||
return frappe.copy_doc(supplier_quotation_records[0]).submit()
|
||||
|
||||
return [
|
||||
(
|
||||
"Quotation -> Sales Order",
|
||||
lambda: make_quotation(),
|
||||
"erpnext.selling.doctype.quotation.quotation.make_sales_order",
|
||||
),
|
||||
(
|
||||
"Sales Order -> Sales Invoice",
|
||||
lambda: make_sales_order(),
|
||||
"erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice",
|
||||
),
|
||||
(
|
||||
"Sales Order -> Delivery Note",
|
||||
lambda: make_sales_order(),
|
||||
"erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
|
||||
),
|
||||
(
|
||||
"Delivery Note -> Sales Invoice",
|
||||
lambda: create_delivery_note(),
|
||||
"erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
|
||||
),
|
||||
(
|
||||
"Material Request -> Purchase Order",
|
||||
lambda: make_material_request(),
|
||||
"erpnext.stock.doctype.material_request.material_request.make_purchase_order",
|
||||
),
|
||||
(
|
||||
"Supplier Quotation -> Purchase Order",
|
||||
make_supplier_quotation,
|
||||
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order",
|
||||
),
|
||||
(
|
||||
"Purchase Order -> Purchase Receipt",
|
||||
lambda: create_purchase_order(),
|
||||
"erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt",
|
||||
),
|
||||
(
|
||||
"Purchase Order -> Purchase Invoice",
|
||||
lambda: create_purchase_order(),
|
||||
"erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice",
|
||||
),
|
||||
(
|
||||
"Purchase Receipt -> Purchase Invoice",
|
||||
lambda: make_purchase_receipt(),
|
||||
"erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice",
|
||||
),
|
||||
(
|
||||
"Purchase Invoice -> Purchase Receipt",
|
||||
lambda: make_purchase_invoice(),
|
||||
"erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt",
|
||||
),
|
||||
]
|
||||
|
||||
def make_quotation(self, item_list, customer):
|
||||
qtn = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ def get_contract_template(template_name, doc):
|
||||
doc = json.loads(doc)
|
||||
|
||||
contract_template = frappe.get_doc("Contract Template", template_name)
|
||||
contract_template.check_permission()
|
||||
contract_terms = None
|
||||
|
||||
if contract_template.contract_terms:
|
||||
|
||||
@@ -227,7 +227,10 @@ class CRMNote(Document):
|
||||
notify_mentions(self.doctype, self.name, note)
|
||||
|
||||
@frappe.whitelist()
|
||||
def edit_note(self, note, row_id):
|
||||
def edit_note(self, note: str, row_id: str):
|
||||
# db_update() skips the write check that save() does in add_note/delete_note
|
||||
self.check_permission("write")
|
||||
|
||||
for d in self.notes:
|
||||
if cstr(d.name) == row_id:
|
||||
d.note = note
|
||||
|
||||
@@ -206,6 +206,43 @@ class TestJobCard(FrappeTestCase):
|
||||
# transfer was made for 2 fg qty in first transfer Stock Entry
|
||||
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
|
||||
|
||||
def test_material_request_stock_entry_uses_job_card_coverage(self):
|
||||
from erpnext.stock.doctype.material_request.material_request import make_stock_entry
|
||||
|
||||
self.transfer_material_against = "Job Card"
|
||||
self.source_warehouse = "Stores - _TC"
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
|
||||
mr = make_material_request(job_card.name)
|
||||
mr.schedule_date = today()
|
||||
for row in mr.items:
|
||||
row.qty = flt(row.qty) / 2
|
||||
row.stock_qty = flt(row.stock_qty) / 2
|
||||
mr.submit()
|
||||
|
||||
stock_entry = make_stock_entry(mr.name)
|
||||
self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2)
|
||||
|
||||
selected_row = mr.items[0]
|
||||
try:
|
||||
frappe.flags.selected_children = {"items": [selected_row.name]}
|
||||
selected_stock_entry = make_stock_entry(mr.name)
|
||||
finally:
|
||||
frappe.flags.selected_children = None
|
||||
|
||||
self.assertEqual(
|
||||
[row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item]
|
||||
)
|
||||
self.assertEqual(selected_stock_entry.fg_completed_qty, 0)
|
||||
|
||||
for row in mr.items:
|
||||
transferred_qty = flt(row.stock_qty) / 2
|
||||
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty)
|
||||
frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty)
|
||||
mr.reload()
|
||||
|
||||
repeated_stock_entry = make_stock_entry(mr.name)
|
||||
self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4)
|
||||
|
||||
@change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
|
||||
def test_job_card_excess_material_transfer(self):
|
||||
"Test transferring more than required RM against Job Card."
|
||||
@@ -616,6 +653,7 @@ class TestJobCard(FrappeTestCase):
|
||||
self.assertEqual(ste.job_card, job_card_name)
|
||||
self.assertEqual(ste.from_bom, 1.0)
|
||||
self.assertEqual(ste.bom_no, work_order.bom_no)
|
||||
self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity"))
|
||||
|
||||
def test_job_card_proccess_qty_and_completed_qty(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
|
||||
@@ -3421,6 +3421,42 @@ class TestWorkOrder(FrappeTestCase):
|
||||
|
||||
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0)
|
||||
|
||||
def test_transferred_qty_sums_item_and_its_alternate(self):
|
||||
# Base item + its alternate transfers must sum onto the required row, not overwrite.
|
||||
fg_item = "Test FG Item For Alternate Transferred Qty"
|
||||
source_warehouse = "Stores - _TC"
|
||||
raw_material = "Test RM For Alternate Transferred Qty"
|
||||
alternate_item = "Alternate Test RM For Alternate Transferred Qty"
|
||||
|
||||
make_item(fg_item, {"is_stock_item": 1})
|
||||
for item in [raw_material, alternate_item]:
|
||||
make_item(item, {"is_stock_item": 1, "allow_alternative_item": 1})
|
||||
test_stock_entry.make_stock_entry(item_code=item, target=source_warehouse, qty=10, basic_rate=100)
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Alternative",
|
||||
"item_code": raw_material,
|
||||
"alternative_item_code": alternate_item,
|
||||
"two_way": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[raw_material])
|
||||
wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse)
|
||||
|
||||
# 6 as the base item
|
||||
frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 6)).submit()
|
||||
# 4 as the alternate item, linked back to the base
|
||||
alt_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 4))
|
||||
alt_transfer.items[0].item_code = alternate_item
|
||||
alt_transfer.items[0].original_item = raw_material
|
||||
alt_transfer.submit()
|
||||
|
||||
wo.reload()
|
||||
self.assertEqual(wo.required_items[0].transferred_qty, 10)
|
||||
self.assertEqual(wo.material_transferred_for_manufacturing, 10)
|
||||
|
||||
def test_components_qty_for_bom_based_manufacture_entry(self):
|
||||
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
|
||||
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)
|
||||
|
||||
@@ -258,7 +258,7 @@ class WorkOrder(Document):
|
||||
PackedItem = frappe.qb.DocType("Packed Item")
|
||||
ProductBundleItem = frappe.qb.DocType("Product Bundle Item")
|
||||
|
||||
so = (
|
||||
so_query = (
|
||||
frappe.qb.from_(SalesOrder)
|
||||
.inner_join(SalesOrderItem)
|
||||
.on(SalesOrderItem.parent == SalesOrder.name)
|
||||
@@ -274,16 +274,23 @@ class WorkOrder(Document):
|
||||
| (ProductBundleItem.item_code == production_item)
|
||||
)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if self.sales_order_item:
|
||||
so_query = so_query.where(SalesOrderItem.name == self.sales_order_item)
|
||||
|
||||
so = so_query.run(as_dict=1)
|
||||
|
||||
if not so:
|
||||
so = (
|
||||
packed_so_query = (
|
||||
frappe.qb.from_(SalesOrder)
|
||||
.inner_join(SalesOrderItem)
|
||||
.on(SalesOrderItem.parent == SalesOrder.name)
|
||||
.inner_join(PackedItem)
|
||||
.on(PackedItem.parent == SalesOrder.name)
|
||||
.on(
|
||||
(PackedItem.parent == SalesOrder.name)
|
||||
& (PackedItem.parent_detail_docname == SalesOrderItem.name)
|
||||
)
|
||||
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
|
||||
.where(
|
||||
(SalesOrder.name == self.sales_order)
|
||||
@@ -292,9 +299,16 @@ class WorkOrder(Document):
|
||||
& (SalesOrder.docstatus == 1)
|
||||
& (PackedItem.item_code == production_item)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if self.sales_order_item:
|
||||
packed_so_query = packed_so_query.where(
|
||||
(PackedItem.name == self.sales_order_item)
|
||||
| (SalesOrderItem.name == self.sales_order_item)
|
||||
)
|
||||
|
||||
so = packed_so_query.run(as_dict=1)
|
||||
|
||||
if len(so):
|
||||
if not self.expected_delivery_date:
|
||||
self.expected_delivery_date = so[0].delivery_date
|
||||
@@ -1272,11 +1286,15 @@ class WorkOrder(Document):
|
||||
& (ste.purpose == "Material Transfer for Manufacture")
|
||||
& (ste.is_return == 0)
|
||||
)
|
||||
.groupby(ste_child.item_code)
|
||||
.groupby(ste_child.item_code, ste_child.original_item)
|
||||
)
|
||||
|
||||
data = query.run(as_dict=1) or []
|
||||
transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
|
||||
# An item's own transfer and its substitutes both key to the original item, so sum them.
|
||||
transferred_items = frappe._dict()
|
||||
for d in data:
|
||||
key = d.original_item or d.item_code
|
||||
transferred_items[key] = flt(transferred_items.get(key)) + flt(d.qty)
|
||||
|
||||
for row in self.required_items:
|
||||
row.db_set(
|
||||
|
||||
@@ -449,3 +449,5 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
erpnext.patches.v16_0.repair_work_order_material_transfer
|
||||
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
|
||||
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
|
||||
erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
|
||||
@@ -0,0 +1,39 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
STANDARD_TRANSACTION_ROLES = [
|
||||
"Sales User",
|
||||
"Sales Manager",
|
||||
"Purchase User",
|
||||
"Purchase Manager",
|
||||
"Stock User",
|
||||
"Stock Manager",
|
||||
"Accounts User",
|
||||
"Accounts Manager",
|
||||
]
|
||||
|
||||
|
||||
def execute():
|
||||
"""Seed SMS Settings.allowed_roles with ERPNext's standard transaction roles."""
|
||||
frappe.reload_doctype("SMS Settings")
|
||||
|
||||
if not frappe.get_meta("SMS Settings").has_field("allowed_roles"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"SMS Settings.allowed_roles not found. Update the Frappe Framework app to a "
|
||||
"version that includes this field, then re-run bench migrate."
|
||||
)
|
||||
)
|
||||
|
||||
sms_settings = frappe.get_single("SMS Settings")
|
||||
existing_roles = {d.role for d in sms_settings.get("allowed_roles")}
|
||||
|
||||
added = False
|
||||
for role in STANDARD_TRANSACTION_ROLES:
|
||||
if role not in existing_roles and frappe.db.exists("Role", role):
|
||||
sms_settings.append("allowed_roles", {"role": role})
|
||||
added = True
|
||||
|
||||
if added:
|
||||
sms_settings.flags.ignore_mandatory = True
|
||||
sms_settings.save()
|
||||
@@ -0,0 +1,20 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
rows = frappe.get_all(
|
||||
"POS Search Fields", filters={"parent": "POS Settings"}, fields=["name", "field", "fieldname"]
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
# the row used to hold the label alone, it now holds "Label (fieldname)"
|
||||
if not (row.field and row.fieldname) or row.field.endswith(f"({row.fieldname})"):
|
||||
continue
|
||||
|
||||
frappe.db.set_value(
|
||||
"POS Search Fields",
|
||||
row.name,
|
||||
"field",
|
||||
f"{row.field} ({row.fieldname})",
|
||||
update_modified=False,
|
||||
)
|
||||
@@ -590,12 +590,16 @@ def allow_to_make_project_update(project, time, frequency):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_duplicate_project(prev_doc, project_name):
|
||||
def create_duplicate_project(prev_doc: str | dict, project_name: str):
|
||||
"""Create duplicate project based on the old project"""
|
||||
import json
|
||||
|
||||
prev_doc = json.loads(prev_doc)
|
||||
|
||||
# prev_doc is caller-supplied, but the tasks below are read from the db by name
|
||||
if source_name := prev_doc.get("name"):
|
||||
frappe.has_permission("Project", "read", source_name, throw=True)
|
||||
|
||||
if project_name == prev_doc.get("name"):
|
||||
frappe.throw(_("Use a name that is different from previous project name"))
|
||||
|
||||
@@ -790,5 +794,6 @@ def calculate_total_purchase_cost(project: str | None = None):
|
||||
@frappe.whitelist()
|
||||
def update_costing_and_billing(project: str | None = None):
|
||||
project = frappe.get_doc("Project", project)
|
||||
project.check_permission("write")
|
||||
project.update_costing()
|
||||
project.db_update()
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Date
|
||||
from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours, time_diff_in_seconds
|
||||
|
||||
from erpnext.controllers.queries import get_match_cond
|
||||
@@ -322,45 +323,62 @@ class Timesheet(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to_time=None):
|
||||
condition = ""
|
||||
tsd = frappe.qb.DocType("Timesheet Detail")
|
||||
ts = frappe.qb.DocType("Timesheet")
|
||||
|
||||
allowed_timesheets = frappe.get_list("Timesheet", pluck="name")
|
||||
allowed_projects = frappe.get_list("Project", pluck="name")
|
||||
|
||||
if not allowed_timesheets:
|
||||
return []
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(tsd)
|
||||
.inner_join(ts)
|
||||
.on(ts.name == tsd.parent)
|
||||
.select(
|
||||
tsd.name.as_("name"),
|
||||
tsd.parent.as_("time_sheet"),
|
||||
tsd.from_time.as_("from_time"),
|
||||
tsd.to_time.as_("to_time"),
|
||||
tsd.billing_hours.as_("billing_hours"),
|
||||
tsd.billing_amount.as_("billing_amount"),
|
||||
tsd.activity_type.as_("activity_type"),
|
||||
tsd.description.as_("description"),
|
||||
ts.currency.as_("currency"),
|
||||
tsd.project_name.as_("project_name"),
|
||||
)
|
||||
.where(
|
||||
(tsd.parenttype == "Timesheet")
|
||||
& (tsd.docstatus == 1)
|
||||
& (tsd.is_billable == 1)
|
||||
& tsd.sales_invoice.isnull()
|
||||
& (tsd.parent.isin(allowed_timesheets))
|
||||
)
|
||||
)
|
||||
|
||||
if allowed_projects:
|
||||
query = query.where((tsd.project.isin(allowed_projects)) | (tsd.project.isnull()))
|
||||
else:
|
||||
query = query.where(tsd.project.isnull())
|
||||
|
||||
if project:
|
||||
condition += "AND tsd.project = %(project)s "
|
||||
query = query.where(tsd.project == project)
|
||||
if parent:
|
||||
condition += "AND tsd.parent = %(parent)s "
|
||||
query = query.where(tsd.parent == parent)
|
||||
if from_time and to_time:
|
||||
condition += "AND CAST(tsd.from_time as DATE) BETWEEN %(from_time)s AND %(to_time)s"
|
||||
query = query.where(Date(tsd.from_time).between(from_time, to_time))
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
tsd.name as name,
|
||||
tsd.parent as time_sheet,
|
||||
tsd.from_time as from_time,
|
||||
tsd.to_time as to_time,
|
||||
tsd.billing_hours as billing_hours,
|
||||
tsd.billing_amount as billing_amount,
|
||||
tsd.activity_type as activity_type,
|
||||
tsd.description as description,
|
||||
ts.currency as currency,
|
||||
tsd.project_name as project_name
|
||||
FROM `tabTimesheet Detail` tsd
|
||||
INNER JOIN `tabTimesheet` ts
|
||||
ON ts.name = tsd.parent
|
||||
WHERE
|
||||
tsd.parenttype = 'Timesheet'
|
||||
AND tsd.docstatus = 1
|
||||
AND tsd.is_billable = 1
|
||||
AND tsd.sales_invoice is NULL
|
||||
{condition}
|
||||
ORDER BY tsd.from_time ASC
|
||||
"""
|
||||
|
||||
filters = {"project": project, "parent": parent, "from_time": from_time, "to_time": to_time}
|
||||
|
||||
return frappe.db.sql(query, filters, as_dict=1)
|
||||
return query.orderby(tsd.from_time).run(as_dict=1)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_timesheet_detail_rate(timelog, currency):
|
||||
allowed_timesheets = frappe.get_list("Timesheet", pluck="name")
|
||||
|
||||
if not allowed_timesheets:
|
||||
return 0.0
|
||||
|
||||
ts = frappe.qb.DocType("Timesheet")
|
||||
ts_detail = frappe.qb.DocType("Timesheet Detail")
|
||||
|
||||
@@ -368,10 +386,20 @@ def get_timesheet_detail_rate(timelog, currency):
|
||||
frappe.qb.from_(ts_detail)
|
||||
.inner_join(ts)
|
||||
.on(ts.name == ts_detail.parent)
|
||||
.select(ts_detail.billing_amount.as_("billing_amount"), ts.currency.as_("currency"))
|
||||
.where(ts_detail.name == timelog)
|
||||
.select(
|
||||
ts_detail.billing_amount.as_("billing_amount"),
|
||||
ts.currency.as_("currency"),
|
||||
ts.name.as_("timesheet"),
|
||||
)
|
||||
.where((ts_detail.name == timelog) & ts_detail.parent.isin(allowed_timesheets))
|
||||
.limit(1)
|
||||
.run(as_dict=1)
|
||||
)[0]
|
||||
)
|
||||
|
||||
if not timelog_detail:
|
||||
return 0.0
|
||||
|
||||
timelog_detail = timelog_detail[0]
|
||||
|
||||
if timelog_detail.currency:
|
||||
exchange_rate = get_exchange_rate(timelog_detail.currency, currency)
|
||||
@@ -386,33 +414,42 @@ def get_timesheet(doctype, txt, searchfield, start, page_len, filters):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
condition = ""
|
||||
if filters.get("project"):
|
||||
condition = "and tsd.project = %(project)s"
|
||||
allowed_timesheets = frappe.get_list("Timesheet", pluck="name")
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""select distinct tsd.parent from `tabTimesheet Detail` tsd,
|
||||
`tabTimesheet` ts where
|
||||
ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and
|
||||
tsd.docstatus = 1 and ts.total_billable_amount > 0
|
||||
and tsd.parent LIKE %(txt)s {condition}
|
||||
order by tsd.parent limit %(page_len)s offset %(start)s""",
|
||||
{
|
||||
"txt": "%" + txt + "%",
|
||||
"start": start,
|
||||
"page_len": page_len,
|
||||
"project": filters.get("project"),
|
||||
},
|
||||
if not allowed_timesheets:
|
||||
return []
|
||||
|
||||
tsd = frappe.qb.DocType("Timesheet Detail")
|
||||
ts = frappe.qb.DocType("Timesheet")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(tsd)
|
||||
.inner_join(ts)
|
||||
.on(tsd.parent == ts.name)
|
||||
.select(tsd.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
ts.status.isin(["Submitted", "Payslip"])
|
||||
& (tsd.docstatus == 1)
|
||||
& (ts.total_billable_amount > 0)
|
||||
& tsd.parent.like(f"%{txt}%")
|
||||
& tsd.parent.isin(allowed_timesheets)
|
||||
)
|
||||
)
|
||||
|
||||
if filters.get("project"):
|
||||
query = query.where(tsd.project == filters.get("project"))
|
||||
|
||||
return query.orderby(tsd.parent).limit(page_len).offset(start).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_timesheet_data(name, project):
|
||||
def get_timesheet_data(name, project=None):
|
||||
data = None
|
||||
if project and project != "":
|
||||
if project:
|
||||
data = get_projectwise_timesheet_data(project, name)
|
||||
else:
|
||||
data = frappe.get_all(
|
||||
data = frappe.get_list(
|
||||
"Timesheet",
|
||||
fields=[
|
||||
"(total_billable_amount - total_billed_amount) as billing_amt",
|
||||
@@ -546,8 +583,14 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20
|
||||
customer = contact.get_link_for("Customer")
|
||||
|
||||
if customer:
|
||||
sales_invoices = frappe.get_all("Sales Invoice", filters={"customer": customer}, pluck="name")
|
||||
sales_invoices = frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={"customer": customer, "docstatus": ["!=", 2]},
|
||||
pluck="name",
|
||||
)
|
||||
projects = frappe.get_all("Project", filters={"customer": customer}, pluck="name")
|
||||
if not (sales_invoices or projects):
|
||||
return []
|
||||
|
||||
# Return timesheet related data to web portal.
|
||||
table = frappe.qb.DocType("Timesheet")
|
||||
@@ -577,10 +620,7 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20
|
||||
if projects:
|
||||
conditions.append(child_table.project.isin(projects))
|
||||
|
||||
if conditions:
|
||||
query = query.where(frappe.qb.terms.Criterion.any(conditions))
|
||||
|
||||
return query.run(as_dict=True)
|
||||
return query.where(frappe.qb.terms.Criterion.any(conditions)).run(as_dict=True)
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -2367,8 +2367,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
method: me.get_method_for_payment(),
|
||||
args: args,
|
||||
callback: function(r) {
|
||||
var doclist = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
|
||||
if (!r.exc) {
|
||||
var doclist = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -982,7 +982,7 @@ erpnext.utils.map_current_doc = function (opts) {
|
||||
|
||||
if (already_set) {
|
||||
frappe.msgprint(
|
||||
__("You have already selected items from {0} {1}", [opts.source_doctype, src])
|
||||
__("You have already selected items from {0} {1}", [__(opts.source_doctype), src])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) {
|
||||
if (
|
||||
!erpnext.utils.validate_mandatory(
|
||||
frm,
|
||||
"Posting / Transaction Date",
|
||||
__("Posting / Transaction Date"),
|
||||
args.posting_date,
|
||||
args.party_type == "Customer" ? "customer" : "supplier"
|
||||
)
|
||||
@@ -92,7 +92,7 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) {
|
||||
if (
|
||||
!erpnext.utils.validate_mandatory(
|
||||
frm,
|
||||
"Company",
|
||||
__("Company"),
|
||||
frm.doc.company,
|
||||
args.party_type == "Customer" ? "customer" : "supplier"
|
||||
)
|
||||
@@ -177,7 +177,7 @@ erpnext.utils.set_taxes_from_address = function (
|
||||
if (
|
||||
!erpnext.utils.validate_mandatory(
|
||||
frm,
|
||||
"Lead / Customer / Supplier",
|
||||
__("Lead / Customer / Supplier"),
|
||||
frm.doc.customer || frm.doc.supplier || frm.doc.lead || frm.doc.party_name,
|
||||
triggered_from_field
|
||||
)
|
||||
@@ -188,7 +188,7 @@ erpnext.utils.set_taxes_from_address = function (
|
||||
if (
|
||||
!erpnext.utils.validate_mandatory(
|
||||
frm,
|
||||
"Posting / Transaction Date",
|
||||
__("Posting / Transaction Date"),
|
||||
frm.doc.posting_date || frm.doc.transaction_date,
|
||||
triggered_from_field
|
||||
)
|
||||
@@ -220,14 +220,14 @@ erpnext.utils.set_taxes_from_address = function (
|
||||
|
||||
erpnext.utils.set_taxes = function (frm, triggered_from_field) {
|
||||
if (frappe.meta.get_docfield(frm.doc.doctype, "taxes")) {
|
||||
if (!erpnext.utils.validate_mandatory(frm, "Company", frm.doc.company, triggered_from_field)) {
|
||||
if (!erpnext.utils.validate_mandatory(frm, __("Company"), frm.doc.company, triggered_from_field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!erpnext.utils.validate_mandatory(
|
||||
frm,
|
||||
"Lead / Customer / Supplier",
|
||||
__("Lead / Customer / Supplier"),
|
||||
frm.doc.customer || frm.doc.supplier || frm.doc.lead || frm.doc.party_name,
|
||||
triggered_from_field
|
||||
)
|
||||
@@ -238,7 +238,7 @@ erpnext.utils.set_taxes = function (frm, triggered_from_field) {
|
||||
if (
|
||||
!erpnext.utils.validate_mandatory(
|
||||
frm,
|
||||
"Posting / Transaction Date",
|
||||
__("Posting / Transaction Date"),
|
||||
frm.doc.posting_date || frm.doc.transaction_date,
|
||||
triggered_from_field
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from frappe.contacts.address_and_contact import (
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
|
||||
from frappe.model.utils.rename_doc import update_linked_doctypes
|
||||
from frappe.utils import cint, cstr, flt, get_formatted_email, today
|
||||
from frappe.utils import cint, cstr, flt, get_formatted_email, get_link_to_form, today
|
||||
from frappe.utils.deprecations import deprecated
|
||||
from frappe.utils.user import get_users_with_role
|
||||
|
||||
@@ -227,10 +227,15 @@ class Customer(TransactionBase):
|
||||
)
|
||||
|
||||
if internal_customer:
|
||||
internal_customer_link = get_link_to_form("Customer", internal_customer)
|
||||
frappe.throw(
|
||||
_("Internal Customer for company {0} already exists").format(
|
||||
frappe.bold(self.represents_company)
|
||||
)
|
||||
_(
|
||||
"Internal Customer {0} already exists for {1}. Disable it to make this Customer internal."
|
||||
).format(
|
||||
internal_customer_link,
|
||||
frappe.bold(self.represents_company),
|
||||
),
|
||||
title=_("Internal Customer Already Exists"),
|
||||
)
|
||||
|
||||
def on_update(self):
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1107,7 +1108,7 @@
|
||||
"idx": 82,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-30 17:40:02.667637",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation",
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe import _
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import cint, flt, getdate, nowdate
|
||||
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
@@ -257,7 +258,11 @@ class Quotation(SellingController):
|
||||
opp.set_status(status=status, update=True)
|
||||
|
||||
@frappe.whitelist()
|
||||
def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None):
|
||||
def declare_enquiry_lost(
|
||||
self, lost_reasons_list: list, competitors: list, detailed_reason: str | None = None
|
||||
):
|
||||
self.check_permission("write")
|
||||
|
||||
if not (self.is_fully_ordered() or self.is_partially_ordered()):
|
||||
get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"])
|
||||
lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons]
|
||||
@@ -387,6 +392,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
|
||||
|
||||
customer = _make_customer(source_name, ignore_permissions)
|
||||
ordered_items = get_ordered_items(source_name)
|
||||
mapped_items = get_qty_already_mapped(target_doc, "quotation_item", "stock_qty")
|
||||
for name, stock_qty in mapped_items.items():
|
||||
ordered_items[name] = flt(ordered_items.get(name)) + stock_qty
|
||||
|
||||
selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
|
||||
|
||||
@@ -440,7 +448,10 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
|
||||
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
|
||||
3. If no selections: Simple row: Map if adequate qty
|
||||
"""
|
||||
if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)):
|
||||
if not (
|
||||
(item.stock_qty > ordered_items.get(item.name, 0.0))
|
||||
or (is_unit_price_row(item) and item.name not in mapped_items)
|
||||
):
|
||||
return False
|
||||
|
||||
if not selected_rows:
|
||||
|
||||
@@ -478,6 +478,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_days": 1,
|
||||
@@ -1679,7 +1680,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-28 12:20:44.130918",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order",
|
||||
|
||||
@@ -22,6 +22,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
validate_inter_company_party,
|
||||
)
|
||||
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
|
||||
validate_against_blanket_order,
|
||||
@@ -977,6 +978,8 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
|
||||
if kwargs.for_reserved_stock:
|
||||
sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name)
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail")
|
||||
|
||||
mapper = {
|
||||
"Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
|
||||
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
|
||||
@@ -1031,15 +1034,17 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
|
||||
return False
|
||||
|
||||
return (
|
||||
(abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc)
|
||||
(abs(doc.delivered_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty))
|
||||
or (is_unit_price_row(doc) and doc.name not in mapped_qty_by_item)
|
||||
) and doc.delivered_by_supplier != 1
|
||||
|
||||
def remaining_qty(source):
|
||||
return flt(source.qty) - flt(source.delivered_qty) - flt(mapped_qty_by_item.get(source.name, 0))
|
||||
|
||||
def update_item(source, target, source_parent):
|
||||
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
|
||||
target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate)
|
||||
target.qty = (
|
||||
flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty)
|
||||
)
|
||||
target.base_amount = remaining_qty(source) * flt(source.base_rate)
|
||||
target.amount = remaining_qty(source) * flt(source.rate)
|
||||
target.qty = flt(source.qty) if is_unit_price_row(source) else remaining_qty(source)
|
||||
|
||||
item = get_item_defaults(target.item_code, source_parent.company)
|
||||
item_group = get_item_group_defaults(target.item_code, source_parent.company)
|
||||
@@ -1137,6 +1142,7 @@ def make_sales_invoice(
|
||||
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
|
||||
billed_qty_by_item = None
|
||||
pending_qty_by_item = {}
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail")
|
||||
|
||||
def is_unit_price_row(source):
|
||||
return has_unit_price_items and source.qty == 0
|
||||
@@ -1165,6 +1171,7 @@ def make_sales_invoice(
|
||||
if source.qty and source.billed_amt:
|
||||
billable_qty -= get_billed_qty_by_item().get(source.name, 0)
|
||||
|
||||
billable_qty -= mapped_qty_by_item.get(source.name, 0)
|
||||
pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0)
|
||||
|
||||
return pending_qty_by_item[source.name]
|
||||
@@ -1244,7 +1251,7 @@ def make_sales_invoice(
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: select_item(doc)
|
||||
and (
|
||||
True
|
||||
doc.name not in mapped_qty_by_item
|
||||
if is_unit_price_row(doc)
|
||||
else (
|
||||
doc.qty
|
||||
@@ -1663,8 +1670,10 @@ def is_product_bundle(item_code):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_work_orders(items, sales_order, company, project=None):
|
||||
def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None):
|
||||
"""Make Work Orders against the given Sales Order for the given `items`"""
|
||||
frappe.has_permission("Sales Order", "read", sales_order, throw=True)
|
||||
|
||||
items = json.loads(items).get("items")
|
||||
out = []
|
||||
|
||||
|
||||
@@ -1730,6 +1730,61 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
)
|
||||
self.assertEqual(wo_qty[0][0], so_item_name.get(item))
|
||||
|
||||
@change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_make_work_order_for_duplicate_product_bundle_rows(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import get_work_order_items
|
||||
|
||||
bundle_item = make_item("_Test Work Order Product Bundle", {"is_stock_item": 0}).name
|
||||
make_product_bundle(bundle_item, ["_Test FG Item"])
|
||||
|
||||
first_delivery_date = add_days(today(), 5)
|
||||
second_delivery_date = add_days(today(), 10)
|
||||
so = make_sales_order(
|
||||
item_list=[
|
||||
{
|
||||
"item_code": bundle_item,
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"delivery_date": first_delivery_date,
|
||||
},
|
||||
{
|
||||
"item_code": bundle_item,
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"delivery_date": second_delivery_date,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
items = [
|
||||
{
|
||||
"warehouse": item.get("warehouse"),
|
||||
"item_code": item.get("item_code"),
|
||||
"pending_qty": item.get("pending_qty"),
|
||||
"sales_order_item": item.get("sales_order_item"),
|
||||
"bom": item.get("bom"),
|
||||
"description": item.get("description"),
|
||||
}
|
||||
for item in get_work_order_items(so.name)
|
||||
]
|
||||
work_orders = make_work_orders(json.dumps({"items": items}), so.name, so.company)
|
||||
|
||||
expected_delivery_dates = {
|
||||
packed_item.name: next(
|
||||
item.delivery_date for item in so.items if item.name == packed_item.parent_detail_docname
|
||||
)
|
||||
for packed_item in so.packed_items
|
||||
}
|
||||
self.assertEqual(len(work_orders), 2)
|
||||
for work_order_name in work_orders:
|
||||
work_order = frappe.get_doc("Work Order", work_order_name)
|
||||
self.assertEqual(
|
||||
getdate(work_order.expected_delivery_date),
|
||||
getdate(expected_delivery_dates[work_order.sales_order_item]),
|
||||
)
|
||||
|
||||
def test_advance_payment_entry_unlink_against_sales_order(self):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Sales Analytics"] = {
|
||||
// "All" reports on every doctype at once and forces the tree to Customer
|
||||
entity_tree_type() {
|
||||
const doc_type = frappe.query_report.get_filter_value("doc_type");
|
||||
return doc_type === "All" ? "Customer" : frappe.query_report.get_filter_value("tree_type");
|
||||
},
|
||||
reset_entity_filter() {
|
||||
const entity_filter = frappe.query_report.get_filter("entity");
|
||||
if (!entity_filter) return;
|
||||
entity_filter.df.label = __(this.entity_tree_type());
|
||||
entity_filter.set_value([]);
|
||||
entity_filter.refresh();
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
fieldname: "tree_type",
|
||||
@@ -18,6 +30,21 @@ frappe.query_reports["Sales Analytics"] = {
|
||||
],
|
||||
default: "Customer",
|
||||
reqd: 1,
|
||||
on_change: function () {
|
||||
frappe.query_reports["Sales Analytics"].reset_entity_filter();
|
||||
frappe.query_report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "entity",
|
||||
label: __("Entity"),
|
||||
fieldtype: "MultiSelectList",
|
||||
get_data: function (txt) {
|
||||
const tree_type = frappe.query_reports["Sales Analytics"].entity_tree_type();
|
||||
if (!tree_type || tree_type === "Order Type") return [];
|
||||
return frappe.db.get_link_options(tree_type, txt);
|
||||
},
|
||||
depends_on: "eval:doc.tree_type != 'Order Type'",
|
||||
},
|
||||
{
|
||||
fieldname: "doc_type",
|
||||
@@ -26,6 +53,10 @@ frappe.query_reports["Sales Analytics"] = {
|
||||
options: ["Sales Order", "Delivery Note", "Sales Invoice"],
|
||||
default: "Sales Invoice",
|
||||
reqd: 1,
|
||||
on_change: function () {
|
||||
frappe.query_reports["Sales Analytics"].reset_entity_filter();
|
||||
frappe.query_report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "value_quantity",
|
||||
|
||||
@@ -18,6 +18,7 @@ def execute(filters=None):
|
||||
class Analytics:
|
||||
def __init__(self, filters=None):
|
||||
self.filters = frappe._dict(filters or {})
|
||||
self.entities = self.filters.get("entity") or []
|
||||
self.date_field = (
|
||||
"transaction_date"
|
||||
if self.filters.doc_type in ["Sales Order", "Purchase Order"]
|
||||
@@ -61,6 +62,7 @@ class Analytics:
|
||||
self.update_company_list_for_parent_company()
|
||||
self.get_columns()
|
||||
self.get_data()
|
||||
self.filter_data_by_entities()
|
||||
self.get_chart_data()
|
||||
|
||||
# Skipping total row for tree-view reports
|
||||
@@ -325,6 +327,23 @@ class Analytics:
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
def filter_data_by_entities(self):
|
||||
if not self.entities:
|
||||
return
|
||||
|
||||
entities = set(self.entities)
|
||||
selected_data = []
|
||||
for row in self.data:
|
||||
if row["entity"] not in entities:
|
||||
continue
|
||||
|
||||
row = row.copy()
|
||||
if "indent" in row:
|
||||
row["indent"] = 0
|
||||
selected_data.append(row)
|
||||
|
||||
self.data = selected_data
|
||||
|
||||
def get_rows(self):
|
||||
self.data = []
|
||||
self.get_periodic_data()
|
||||
|
||||
253
erpnext/selling/report/sales_analytics/test_sales_analytics.py
Normal file
253
erpnext/selling/report/sales_analytics/test_sales_analytics.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_analytics.sales_analytics import execute
|
||||
|
||||
# Bootstrap masters reused as-is (see erpnext/tests/utils.py):
|
||||
# "_Test Customer" -> customer_group "_Test Customer Group", territory "_Test Territory"
|
||||
# "_Test Supplier" -> supplier_group "_Test Supplier Group" (child of "All Supplier Groups")
|
||||
# Sales Order.order_type defaults to "Sales" (reqd Select field)
|
||||
COMPANY = "_Test Company"
|
||||
CUSTOMER = "_Test Customer"
|
||||
CUSTOMER_GROUP = "_Test Customer Group"
|
||||
TERRITORY = "_Test Territory"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
SUPPLIER_GROUP = "_Test Supplier Group"
|
||||
FROM_DATE = "2019-04-01"
|
||||
TO_DATE = "2019-06-30"
|
||||
|
||||
|
||||
class TestSalesAnalytics(FrappeTestCase):
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.created_docs = []
|
||||
# Two submitted Sales Orders for the bootstrap customer inside the report window.
|
||||
# These roll up into the tree roots the converted tree/order-type queries build.
|
||||
self.orders = [
|
||||
self.make_so(qty=5, rate=100, transaction_date="2019-04-10"),
|
||||
self.make_so(qty=3, rate=100, transaction_date="2019-05-15"),
|
||||
]
|
||||
|
||||
def tearDown(self):
|
||||
for doctype, name in reversed(self.created_docs):
|
||||
if not frappe.db.exists(doctype, name):
|
||||
continue
|
||||
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
if doc.docstatus == 1:
|
||||
doc.cancel()
|
||||
frappe.delete_doc(doctype, name, force=True)
|
||||
|
||||
super().tearDown()
|
||||
|
||||
def make_so(self, qty, rate, transaction_date, order_type=None):
|
||||
so = make_sales_order(
|
||||
company=COMPANY,
|
||||
customer=CUSTOMER,
|
||||
qty=qty,
|
||||
rate=rate,
|
||||
transaction_date=transaction_date,
|
||||
do_not_save=True,
|
||||
)
|
||||
# v15's test helper does not populate these hidden analytics dimensions.
|
||||
so.customer_group = CUSTOMER_GROUP
|
||||
so.territory = TERRITORY
|
||||
if order_type:
|
||||
so.order_type = order_type
|
||||
so.insert()
|
||||
so.submit()
|
||||
self.created_docs.append((so.doctype, so.name))
|
||||
return so
|
||||
|
||||
def _base_filters(self, **overrides):
|
||||
filters = {
|
||||
"doc_type": "Sales Order",
|
||||
"value_quantity": "Value",
|
||||
"range": "Monthly",
|
||||
"company": COMPANY,
|
||||
"from_date": FROM_DATE,
|
||||
"to_date": TO_DATE,
|
||||
}
|
||||
filters.update(overrides)
|
||||
return filters
|
||||
|
||||
def _expected_value_total(self):
|
||||
return sum(flt(so.base_net_total) for so in self.orders)
|
||||
|
||||
def _expected_qty_total(self):
|
||||
return sum(flt(so.total_qty) for so in self.orders)
|
||||
|
||||
def _row_by_entity(self, data):
|
||||
return {row["entity"]: row for row in data}
|
||||
|
||||
def test_customer_entity_filter(self):
|
||||
_columns, data, _message, chart, *_rest = execute(
|
||||
self._base_filters(tree_type="Customer", entity=[CUSTOMER], curves="all")
|
||||
)
|
||||
|
||||
self.assertEqual({row["entity"] for row in data}, {CUSTOMER})
|
||||
self.assertAlmostEqual(data[0]["total"], self._expected_value_total(), places=2)
|
||||
self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {CUSTOMER})
|
||||
|
||||
def test_parent_customer_group_filter_preserves_rollup(self):
|
||||
_columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group"))
|
||||
_columns, filtered_data, *_rest = execute(
|
||||
self._base_filters(tree_type="Customer Group", entity=["All Customer Groups"])
|
||||
)
|
||||
|
||||
unfiltered = self._row_by_entity(unfiltered_data)
|
||||
filtered = self._row_by_entity(filtered_data)
|
||||
self.assertEqual(set(filtered), {"All Customer Groups"})
|
||||
self.assertAlmostEqual(
|
||||
filtered["All Customer Groups"]["total"],
|
||||
unfiltered["All Customer Groups"]["total"],
|
||||
places=2,
|
||||
)
|
||||
|
||||
def test_customer_group_entity_filter(self):
|
||||
_columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group"))
|
||||
_columns, filtered_data, *_rest = execute(
|
||||
self._base_filters(tree_type="Customer Group", entity=[CUSTOMER_GROUP])
|
||||
)
|
||||
|
||||
unfiltered = self._row_by_entity(unfiltered_data)
|
||||
filtered = self._row_by_entity(filtered_data)
|
||||
self.assertEqual(set(filtered), {CUSTOMER_GROUP})
|
||||
self.assertEqual(filtered[CUSTOMER_GROUP]["indent"], 0)
|
||||
self.assertAlmostEqual(
|
||||
filtered[CUSTOMER_GROUP]["total"], unfiltered[CUSTOMER_GROUP]["total"], places=2
|
||||
)
|
||||
|
||||
def test_customer_group_tree_rolls_up_to_root(self):
|
||||
"""tree_type='Customer Group' drives get_groups (tree get_all ordered by lft)
|
||||
and get_rows_by_group, rolling child values up to the 'All Customer Groups' root."""
|
||||
columns, data, *_ = execute(self._base_filters(tree_type="Customer Group"))
|
||||
|
||||
self.assertTrue(columns)
|
||||
self.assertTrue(data)
|
||||
|
||||
rows = self._row_by_entity(data)
|
||||
# The whole tree is returned, so both the root and the customer's own group appear.
|
||||
self.assertIn("All Customer Groups", rows)
|
||||
self.assertIn(CUSTOMER_GROUP, rows)
|
||||
|
||||
expected = self._expected_value_total()
|
||||
self.assertGreater(expected, 0)
|
||||
# Leaf group holds the orders; root receives the same total via roll-up.
|
||||
self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected, places=2)
|
||||
self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected, places=2)
|
||||
# Roots of a tree report sit at indent 0.
|
||||
self.assertEqual(rows["All Customer Groups"]["indent"], 0)
|
||||
|
||||
def test_territory_tree_rolls_up_to_root(self):
|
||||
"""tree_type='Territory' exercises the same tree path against the Territory tree."""
|
||||
columns, data, *_ = execute(self._base_filters(tree_type="Territory"))
|
||||
|
||||
self.assertTrue(columns)
|
||||
rows = self._row_by_entity(data)
|
||||
self.assertIn("All Territories", rows)
|
||||
self.assertIn(TERRITORY, rows)
|
||||
|
||||
expected = self._expected_value_total()
|
||||
self.assertAlmostEqual(rows[TERRITORY]["total"], expected, places=2)
|
||||
self.assertAlmostEqual(rows["All Territories"]["total"], expected, places=2)
|
||||
|
||||
def test_order_type_synthetic_tree(self):
|
||||
"""tree_type='Order Type' drives get_teams: distinct order_type rebuilt in Python
|
||||
under a synthetic 'Order Types' root, then rolled up via get_rows_by_group."""
|
||||
columns, data, *_ = execute(self._base_filters(tree_type="Order Type"))
|
||||
|
||||
self.assertTrue(columns)
|
||||
rows = self._row_by_entity(data)
|
||||
# Synthetic root plus the default order_type the bootstrap Sales Orders carry.
|
||||
self.assertIn("Order Types", rows)
|
||||
self.assertIn("Sales", rows)
|
||||
self.assertEqual(rows["Order Types"]["indent"], 0)
|
||||
|
||||
expected = self._expected_value_total()
|
||||
self.assertAlmostEqual(rows["Sales"]["total"], expected, places=2)
|
||||
self.assertAlmostEqual(rows["Order Types"]["total"], expected, places=2)
|
||||
|
||||
def test_order_type_leaf_rows_in_sorted_order(self):
|
||||
"""get_teams fetches distinct order_types; frappe drops the SQL ORDER BY for distinct queries on
|
||||
postgres, so the report sorts the order-type rows in python (key=str.casefold) to keep them in a
|
||||
deterministic, case-insensitive order identical on both engines."""
|
||||
for order_type in ("Shopping Cart", "Maintenance", "Sales"): # created out of sorted order
|
||||
self.make_so(
|
||||
qty=1,
|
||||
rate=100,
|
||||
transaction_date="2019-04-12",
|
||||
order_type=order_type,
|
||||
)
|
||||
|
||||
columns, data, *_ = execute(self._base_filters(tree_type="Order Type"))
|
||||
|
||||
mine = {"Sales", "Maintenance", "Shopping Cart"}
|
||||
leaves = [row["entity"] for row in data if row.get("entity") in mine]
|
||||
# the order-type rows must appear in casefold-sorted order on both engines
|
||||
self.assertEqual(leaves, sorted(leaves, key=str.casefold))
|
||||
self.assertEqual(set(leaves), mine)
|
||||
|
||||
def test_customer_group_by_quantity(self):
|
||||
"""value_quantity='Quantity' switches the selected value column (total_qty)."""
|
||||
_columns, data, *_ = execute(
|
||||
self._base_filters(tree_type="Customer Group", value_quantity="Quantity")
|
||||
)
|
||||
|
||||
rows = self._row_by_entity(data)
|
||||
self.assertIn(CUSTOMER_GROUP, rows)
|
||||
|
||||
expected_qty = self._expected_qty_total()
|
||||
self.assertGreater(expected_qty, 0)
|
||||
self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected_qty, places=2)
|
||||
self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected_qty, places=2)
|
||||
|
||||
def test_supplier_group_tree_maps_supplier_to_group(self):
|
||||
"""tree_type='Supplier Group' (doc_type='Purchase Order') exercises
|
||||
get_supplier_parent_child_map: the query selects 'supplier' as entity, then
|
||||
get_periodic_data remaps each supplier to its group via the parent->child map
|
||||
built by frappe.get_all('Supplier', ['name', 'supplier_group'], as_list=True).
|
||||
The group total then rolls up into the 'All Supplier Groups' root."""
|
||||
# Baseline the report before adding our Purchase Order so the assertion is
|
||||
# robust to any pre-existing rows in the historical window.
|
||||
base_filters = self._base_filters(tree_type="Supplier Group", doc_type="Purchase Order")
|
||||
_columns, base_data, *_ = execute(base_filters)
|
||||
base_rows = self._row_by_entity(base_data)
|
||||
base_group_total = flt(base_rows.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
|
||||
po = create_purchase_order(
|
||||
company=COMPANY,
|
||||
supplier=SUPPLIER,
|
||||
qty=4,
|
||||
rate=250,
|
||||
transaction_date="2019-04-10",
|
||||
)
|
||||
self.created_docs.append((po.doctype, po.name))
|
||||
po_value = flt(po.base_net_total)
|
||||
self.assertGreater(po_value, 0)
|
||||
|
||||
columns, data, *_ = execute(base_filters)
|
||||
|
||||
self.assertTrue(columns)
|
||||
self.assertTrue(data)
|
||||
|
||||
rows = self._row_by_entity(data)
|
||||
# The supplier was remapped to its group; both the leaf group and the tree
|
||||
# root appear as entities (no raw supplier name leaks into the output).
|
||||
self.assertIn(SUPPLIER_GROUP, rows)
|
||||
self.assertIn("All Supplier Groups", rows)
|
||||
self.assertNotIn(SUPPLIER, rows)
|
||||
# Roots of a tree report sit at indent 0.
|
||||
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
|
||||
|
||||
# The new PO lands in the supplier's group via the parent->child map.
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group_total, po_value, places=2)
|
||||
# Roll-up: the root aggregates every group, so it covers at least this PO.
|
||||
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), po_value)
|
||||
@@ -667,6 +667,13 @@ class Company(NestedSet):
|
||||
"""
|
||||
Trash accounts and cost centers for this company if no gl entry exists
|
||||
"""
|
||||
if frappe.db.get_single_value("Global Defaults", "demo_company") == self.name:
|
||||
frappe.throw(
|
||||
_("{0} is the site's Demo Company and cannot be deleted directly. Use {1} instead.").format(
|
||||
self.name, _("Delete Demo Data")
|
||||
)
|
||||
)
|
||||
|
||||
NestedSet.validate_if_child_exists(self)
|
||||
frappe.utils.nestedset.update_nsm(self)
|
||||
|
||||
|
||||
@@ -1395,6 +1395,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1413,6 +1414,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1431,6 +1433,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1449,6 +1452,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1467,6 +1471,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1485,6 +1490,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1507,6 +1513,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1525,6 +1532,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1543,6 +1551,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1561,6 +1570,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1579,6 +1589,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1597,6 +1608,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1628,6 +1640,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1637,6 +1650,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1646,6 +1660,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1655,6 +1670,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1664,6 +1680,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1673,6 +1690,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1682,6 +1700,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1691,6 +1710,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1700,6 +1720,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1709,6 +1730,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1718,6 +1740,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1727,6 +1750,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -1735,6 +1759,7 @@
|
||||
"account_number": "1433",
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
}
|
||||
]
|
||||
@@ -2158,6 +2183,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2176,6 +2202,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2194,6 +2221,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2212,6 +2240,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2230,6 +2259,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2248,6 +2278,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2270,6 +2301,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2288,6 +2320,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2306,6 +2339,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2324,6 +2358,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2342,6 +2377,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2360,6 +2396,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2391,6 +2428,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2400,6 +2438,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2409,6 +2448,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2418,6 +2458,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2427,6 +2468,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2436,6 +2478,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2445,6 +2488,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2454,6 +2498,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2463,6 +2508,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2472,6 +2518,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2481,6 +2528,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2490,6 +2538,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2498,6 +2547,7 @@
|
||||
"account_number": "1588",
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
}
|
||||
]
|
||||
@@ -2921,6 +2971,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2939,6 +2990,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2957,6 +3009,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2975,6 +3028,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -2993,6 +3047,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3011,6 +3066,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3033,6 +3089,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3051,6 +3108,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3069,7 +3127,8 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"tax_rate": 19.00
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
"tax_type": {
|
||||
@@ -3087,6 +3146,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3105,6 +3165,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3123,6 +3184,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3154,6 +3216,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3163,6 +3226,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3172,6 +3236,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3181,6 +3246,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3190,6 +3256,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3199,6 +3266,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3208,6 +3276,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3217,6 +3286,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3226,6 +3296,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3235,6 +3306,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3244,6 +3316,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3253,6 +3326,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3261,6 +3335,7 @@
|
||||
"account_number": "1550",
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
}
|
||||
]
|
||||
@@ -3653,6 +3728,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3669,6 +3745,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3685,6 +3762,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3701,6 +3779,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3717,6 +3796,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3733,6 +3813,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3753,6 +3834,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3769,6 +3851,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3785,6 +3868,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3801,6 +3885,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3817,6 +3902,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3833,6 +3919,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3861,6 +3948,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3869,6 +3957,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3877,6 +3966,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3885,6 +3975,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3893,6 +3984,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3901,6 +3993,7 @@
|
||||
"root_type": "Liability",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3909,6 +4002,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3917,6 +4011,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3925,6 +4020,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3933,6 +4029,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3941,6 +4038,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 19.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3949,6 +4047,7 @@
|
||||
"root_type": "Asset",
|
||||
"tax_rate": 7.00
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
},
|
||||
{
|
||||
@@ -3956,6 +4055,7 @@
|
||||
"account_name": "Entstandene Einfuhrumsatzsteuer",
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"not_applicable": 1,
|
||||
"tax_rate": 0.00
|
||||
}
|
||||
]
|
||||
|
||||
@@ -28,6 +28,68 @@ class TestBin(FrappeTestCase):
|
||||
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_repost_resets_bin_without_sle(self):
|
||||
"""A repost must zero the bin when the ledger is empty, e.g. after entries were deleted."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.stock_ledger import update_entries_after
|
||||
|
||||
item_code = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
|
||||
|
||||
# deleting a transaction with `delete_linked_ledger_entries` on drops its entries outright
|
||||
frappe.db.delete("Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse})
|
||||
|
||||
update_entries_after(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": "1900-01-01",
|
||||
"posting_time": "00:01",
|
||||
}
|
||||
)
|
||||
|
||||
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
self.assertEqual(bin.actual_qty, 0)
|
||||
self.assertEqual(bin.valuation_rate, 0)
|
||||
self.assertEqual(bin.stock_value, 0)
|
||||
|
||||
def test_cancelling_last_entry_resets_bin(self):
|
||||
"""Cancelling the only voucher must clear stock value, not just quantity."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item_code = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
|
||||
|
||||
se.cancel()
|
||||
|
||||
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
self.assertEqual(bin.actual_qty, 0)
|
||||
self.assertEqual(bin.valuation_rate, 0)
|
||||
self.assertEqual(bin.stock_value, 0)
|
||||
|
||||
def test_deleting_last_voucher_resets_bin(self):
|
||||
"""Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item_code = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
delete_entries = frappe.get_single_value("Accounts Settings", "delete_linked_ledger_entries")
|
||||
frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 1)
|
||||
|
||||
try:
|
||||
se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
|
||||
se.cancel()
|
||||
frappe.delete_doc("Stock Entry", se.name, force=1)
|
||||
finally:
|
||||
frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", delete_entries)
|
||||
|
||||
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
self.assertEqual(bin.actual_qty, 0)
|
||||
self.assertEqual(bin.valuation_rate, 0)
|
||||
self.assertEqual(bin.stock_value, 0)
|
||||
|
||||
def test_index_exists(self):
|
||||
indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1)
|
||||
if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes):
|
||||
|
||||
@@ -428,6 +428,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1404,7 +1405,7 @@
|
||||
"idx": 146,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-03 12:27:19.055918",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Note",
|
||||
|
||||
@@ -18,6 +18,7 @@ from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
from erpnext.stock.stock_ledger import validate_reserved_stock
|
||||
|
||||
@@ -936,6 +937,8 @@ def make_sales_invoice(
|
||||
to_make_invoice_qty_map = {}
|
||||
returned_qty_map = get_returned_qty_map(source_name)
|
||||
invoiced_qty_map = get_invoiced_qty_map(source_name)
|
||||
for ref, qty in get_qty_already_mapped(target_doc, "dn_detail").items():
|
||||
invoiced_qty_map[ref] = invoiced_qty_map.get(ref, 0) + qty
|
||||
|
||||
def set_missing_values(source, target):
|
||||
target.run_method("set_missing_values")
|
||||
@@ -1008,7 +1011,7 @@ def make_sales_invoice(
|
||||
"postprocess": update_item,
|
||||
"filter": lambda d: get_pending_qty(d) <= 0
|
||||
if not doc.get("is_return")
|
||||
else get_pending_qty(d) > 0,
|
||||
else get_pending_qty(d) >= 0,
|
||||
"condition": select_item,
|
||||
},
|
||||
"Sales Taxes and Charges": {
|
||||
@@ -1263,7 +1266,8 @@ def make_sales_return(source_name, target_doc=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_delivery_note_status(docname, status):
|
||||
dn = frappe.get_doc("Delivery Note", docname)
|
||||
dn = frappe.get_lazy_doc("Delivery Note", docname)
|
||||
dn.check_permission("submit")
|
||||
dn.update_status(status)
|
||||
|
||||
|
||||
|
||||
@@ -1091,6 +1091,25 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
self.assertEqual(dn2.per_billed, 100)
|
||||
self.assertEqual(dn2.status, "Completed")
|
||||
|
||||
def test_mapping_same_dn_twice_is_idempotent(self):
|
||||
# "Get Items From > Delivery Note" passes the in-progress invoice back as target_doc.
|
||||
# Selecting the same DN again must not append a second row for the same dn_detail.
|
||||
dn = create_delivery_note(qty=5)
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
self.assertEqual(len(si.items), 1)
|
||||
self.assertEqual(si.items[0].qty, 5)
|
||||
|
||||
si = make_sales_invoice(dn.name, target_doc=si)
|
||||
self.assertEqual(len(si.items), 1)
|
||||
self.assertEqual(si.items[0].qty, 5)
|
||||
|
||||
# a partly reduced draft row still tops up to the delivered qty
|
||||
si.items[0].qty = 2
|
||||
si = make_sales_invoice(dn.name, target_doc=si)
|
||||
self.assertEqual(len(si.items), 2)
|
||||
self.assertEqual([d.qty for d in si.items], [2, 3])
|
||||
|
||||
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": True})
|
||||
def test_sales_invoice_qty_after_return(self):
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
@@ -13,10 +13,13 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
|
||||
DoNotChangeError,
|
||||
delete_dimension,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.item.test_item import create_item, make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import InventoryDimensionNegativeStockError
|
||||
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import (
|
||||
InventoryDimensionNegativeStockError,
|
||||
SerialNoInventoryDimensionError,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
|
||||
@@ -504,6 +507,193 @@ class TestInventoryDimension(FrappeTestCase):
|
||||
|
||||
self.assertEqual(site_name, "Site 1")
|
||||
|
||||
def test_serial_no_cannot_be_issued_from_incorrect_inventory_dimension(self):
|
||||
item = make_item(
|
||||
"Test Serialized Inventory Dimension Item",
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Inventory Dimension Serial No"
|
||||
warehouse = create_warehouse("Serialized Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
apply_to_all_doctypes=1,
|
||||
dimension_name="Serial Rack",
|
||||
reference_document="Rack",
|
||||
validate_negative_stock=0,
|
||||
)
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item.name,
|
||||
to_warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_submit=True,
|
||||
)
|
||||
receipt.items[0].to_serial_rack = "Rack 1"
|
||||
receipt.save()
|
||||
receipt.submit()
|
||||
|
||||
transfer = make_stock_entry(
|
||||
item_code=item.name,
|
||||
from_warehouse=warehouse,
|
||||
to_warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_submit=True,
|
||||
)
|
||||
transfer.items[0].serial_rack = "Rack 1"
|
||||
transfer.items[0].to_serial_rack = "Rack 2"
|
||||
transfer.save()
|
||||
transfer.submit()
|
||||
|
||||
issue = make_stock_entry(
|
||||
item_code=item.name,
|
||||
from_warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_submit=True,
|
||||
)
|
||||
issue.items[0].serial_rack = "Rack 1"
|
||||
issue.save()
|
||||
|
||||
self.assertRaises(SerialNoInventoryDimensionError, issue.submit)
|
||||
self.assertFalse(
|
||||
frappe.db.exists(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": issue.name, "is_cancelled": 0},
|
||||
)
|
||||
)
|
||||
|
||||
def test_serial_no_cannot_move_from_empty_inventory_dimension(self):
|
||||
item = make_item(
|
||||
"Test Serialized Empty Inventory Dimension Item",
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Empty Inventory Dimension Serial No"
|
||||
warehouse = create_warehouse("Serialized Empty Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
apply_to_all_doctypes=1,
|
||||
dimension_name="Empty Serial Rack",
|
||||
reference_document="Rack",
|
||||
validate_negative_stock=0,
|
||||
)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
to_warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
|
||||
issue = make_stock_entry(
|
||||
item_code=item.name,
|
||||
from_warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_submit=True,
|
||||
)
|
||||
issue.items[0].empty_serial_rack = "Rack 1"
|
||||
issue.save()
|
||||
|
||||
self.assertRaises(SerialNoInventoryDimensionError, issue.submit)
|
||||
|
||||
def test_serial_no_cannot_be_issued_without_inventory_dimension(self):
|
||||
item = make_item(
|
||||
"Test Serialized Required Inventory Dimension Item",
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Required Inventory Dimension Serial No"
|
||||
warehouse = create_warehouse("Serialized Required Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
apply_to_all_doctypes=1,
|
||||
dimension_name="Required Serial Rack",
|
||||
reference_document="Rack",
|
||||
validate_negative_stock=0,
|
||||
)
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item.name,
|
||||
to_warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_submit=True,
|
||||
)
|
||||
receipt.items[0].to_required_serial_rack = "Rack 1"
|
||||
receipt.save()
|
||||
receipt.submit()
|
||||
|
||||
issue = make_stock_entry(
|
||||
item_code=item.name,
|
||||
from_warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_submit=True,
|
||||
)
|
||||
issue.save()
|
||||
|
||||
self.assertRaises(SerialNoInventoryDimensionError, issue.submit)
|
||||
self.assertFalse(
|
||||
frappe.db.exists(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": issue.name, "is_cancelled": 0},
|
||||
)
|
||||
)
|
||||
|
||||
def test_serial_no_inventory_dimension_with_legacy_inward_sle(self):
|
||||
item = make_item(
|
||||
"Test Serialized Legacy Inventory Dimension Item",
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Legacy Inventory Dimension Serial No"
|
||||
warehouse = create_warehouse("Serialized Legacy Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
apply_to_all_doctypes=1,
|
||||
dimension_name="Legacy Serial Rack",
|
||||
reference_document="Rack",
|
||||
validate_negative_stock=0,
|
||||
)
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item.name,
|
||||
to_warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_submit=True,
|
||||
)
|
||||
receipt.items[0].to_legacy_serial_rack = "Rack 2"
|
||||
receipt.save()
|
||||
receipt.submit()
|
||||
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": receipt.name, "actual_qty": (">", 0), "is_cancelled": 0},
|
||||
{"serial_and_batch_bundle": None, "serial_no": f"Other Legacy Serial, {serial_no}"},
|
||||
)
|
||||
|
||||
issue = make_stock_entry(
|
||||
item_code=item.name,
|
||||
from_warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_submit=True,
|
||||
)
|
||||
issue.items[0].legacy_serial_rack = "Rack 1"
|
||||
issue.save()
|
||||
|
||||
self.assertRaises(SerialNoInventoryDimensionError, issue.submit)
|
||||
|
||||
def test_validate_negative_stock_with_multiple_dimension(self):
|
||||
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0)
|
||||
item_code = "Test Negative Multi Inventory Dimension Item"
|
||||
@@ -709,13 +899,16 @@ def create_inventory_dimension(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
if frappe.db.exists("Inventory Dimension", args.dimension_name):
|
||||
return frappe.get_doc("Inventory Dimension", args.dimension_name)
|
||||
doc = frappe.get_doc("Inventory Dimension", args.dimension_name)
|
||||
else:
|
||||
doc = frappe.new_doc("Inventory Dimension")
|
||||
doc.update(args)
|
||||
|
||||
doc = frappe.new_doc("Inventory Dimension")
|
||||
doc.update(args)
|
||||
if not args.do_not_save:
|
||||
doc.insert(ignore_permissions=True)
|
||||
|
||||
if not args.do_not_save:
|
||||
doc.insert(ignore_permissions=True)
|
||||
frappe.local.inventory_dimensions = {}
|
||||
frappe.local.document_wise_inventory_dimensions = {}
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
@@ -290,7 +290,7 @@ class LandedCostVoucher(Document):
|
||||
# update stock & gl entries for cancelled state of PR
|
||||
doc.docstatus = 2
|
||||
doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=True)
|
||||
doc.make_gl_entries_on_cancel()
|
||||
doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
|
||||
# update stock & gl entries for submit state of PR
|
||||
doc.docstatus = 1
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import frappe
|
||||
import frappe.defaults
|
||||
from frappe import _, msgprint
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder import Order
|
||||
from frappe.query_builder.functions import Sum
|
||||
@@ -17,6 +18,7 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_se
|
||||
|
||||
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
from erpnext.stock.get_item_details import get_price_list_rate_for
|
||||
@@ -94,6 +96,22 @@ class MaterialRequest(BuyingController):
|
||||
def check_if_already_pulled(self):
|
||||
pass
|
||||
|
||||
def validate_with_previous_doc(self):
|
||||
super().validate_with_previous_doc(
|
||||
{
|
||||
"Sales Order": {
|
||||
"ref_dn_field": "sales_order",
|
||||
"compare_fields": [["company", "="]],
|
||||
},
|
||||
"Sales Order Item": {
|
||||
"ref_dn_field": "sales_order_item",
|
||||
"compare_fields": [["item_code", "="], ["uom", "="], ["conversion_factor", "="]],
|
||||
"is_child_table": True,
|
||||
"allow_duplicate_prev_row_id": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def validate_qty_against_so(self):
|
||||
so_items = {} # Format --> {'SO/00001': {'Item/001': 120, 'Item/002': 24}}
|
||||
for d in self.get("items"):
|
||||
@@ -136,6 +154,7 @@ class MaterialRequest(BuyingController):
|
||||
|
||||
self.validate_schedule_date()
|
||||
self.check_for_on_hold_or_closed_status("Sales Order", "sales_order")
|
||||
self.validate_with_previous_doc()
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_material_request_type()
|
||||
|
||||
@@ -482,6 +501,8 @@ def make_purchase_order(source_name, target_doc=None, args=None):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "material_request_item", "stock_qty")
|
||||
|
||||
def postprocess(source, target_doc):
|
||||
if frappe.flags.args and frappe.flags.args.default_supplier:
|
||||
# items only for given default supplier
|
||||
@@ -498,7 +519,7 @@ def make_purchase_order(source_name, target_doc=None, args=None):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
child_filter = d.name in filtered_items if filtered_items else True
|
||||
|
||||
qty = d.ordered_qty or d.received_qty
|
||||
qty = (d.ordered_qty or d.received_qty) + flt(mapped_qty_by_item.get(d.name, 0))
|
||||
|
||||
return qty < d.stock_qty and child_filter
|
||||
|
||||
@@ -534,7 +555,16 @@ def make_purchase_order(source_name, target_doc=None, args=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_request_for_quotation(source_name, target_doc=None):
|
||||
def make_request_for_quotation(source_name: str, target_doc: str | dict | Document | None = None):
|
||||
def update_item(obj, target, source_parent):
|
||||
qty = obj.ordered_qty or obj.received_qty
|
||||
target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor
|
||||
target.stock_qty = target.qty * target.conversion_factor
|
||||
|
||||
def select_item(d):
|
||||
qty = d.ordered_qty or d.received_qty
|
||||
return qty < d.stock_qty
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Material Request",
|
||||
source_name,
|
||||
@@ -550,6 +580,8 @@ def make_request_for_quotation(source_name, target_doc=None):
|
||||
["parent", "material_request"],
|
||||
["project", "project_name"],
|
||||
],
|
||||
"postprocess": update_item,
|
||||
"condition": select_item,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
@@ -774,6 +806,8 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
target.fg_completed_qty = job_card_details[0].for_quantity
|
||||
target.from_bom = 1
|
||||
|
||||
target.cap_completed_qty_to_material_coverage()
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Material Request",
|
||||
source_name,
|
||||
|
||||
@@ -15,6 +15,7 @@ from erpnext.stock.doctype.material_request.material_request import (
|
||||
create_pick_list,
|
||||
make_in_transit_stock_entry,
|
||||
make_purchase_order,
|
||||
make_request_for_quotation,
|
||||
make_stock_entry,
|
||||
make_supplier_quotation,
|
||||
raise_work_orders,
|
||||
@@ -47,6 +48,26 @@ class TestMaterialRequest(FrappeTestCase):
|
||||
self.assertEqual(po.doctype, "Purchase Order")
|
||||
self.assertEqual(len(po.get("items")), len(mr.get("items")))
|
||||
|
||||
def test_make_request_for_quotation_skips_ordered_items(self):
|
||||
mr = frappe.copy_doc(test_records[0]).insert()
|
||||
mr = frappe.get_doc("Material Request", mr.name)
|
||||
mr.submit()
|
||||
|
||||
# fully order the first item, leave the second pending
|
||||
po = make_purchase_order(mr.name)
|
||||
po.supplier = "_Test Supplier"
|
||||
po.schedule_date = today()
|
||||
po.items = [po.items[0]]
|
||||
po.items[0].schedule_date = today()
|
||||
po.insert()
|
||||
po.submit()
|
||||
|
||||
rfq = make_request_for_quotation(mr.name)
|
||||
|
||||
self.assertEqual(len(rfq.get("items")), 1)
|
||||
self.assertEqual(rfq.items[0].material_request_item, mr.items[1].name)
|
||||
self.assertEqual(rfq.items[0].qty, mr.items[1].qty)
|
||||
|
||||
def test_make_supplier_quotation(self):
|
||||
mr = frappe.copy_doc(test_records[0]).insert()
|
||||
|
||||
@@ -919,6 +940,18 @@ class TestMaterialRequest(FrappeTestCase):
|
||||
|
||||
self.assertRaises(OverAllowanceError, mr.submit)
|
||||
|
||||
def test_item_change_on_sales_order_row_is_blocked(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_material_request
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
other_item = create_item("_Test MR Item Swap").name
|
||||
so = make_sales_order()
|
||||
mr = make_material_request(so.name)
|
||||
mr.material_request_type = "Purchase"
|
||||
# swapping the fetched item would leave a stale link to the SO row
|
||||
mr.items[0].item_code = other_item
|
||||
self.assertRaises(frappe.ValidationError, mr.insert)
|
||||
|
||||
def test_pending_qty_in_pick_list(self):
|
||||
"""Test for pick list mapped doc qty from partially received Material Request Transfer"""
|
||||
import json
|
||||
|
||||
@@ -1508,6 +1508,7 @@ def add_product_bundles_to_delivery_note(
|
||||
@frappe.whitelist()
|
||||
def create_stock_entry(pick_list: str | dict):
|
||||
pick_list = frappe.get_doc(frappe.parse_json(pick_list))
|
||||
pick_list.check_permission("read")
|
||||
validate_item_locations(pick_list)
|
||||
|
||||
stock_entry = frappe.new_doc("Stock Entry")
|
||||
|
||||
@@ -1078,6 +1078,58 @@ class TestPickList(FrappeTestCase):
|
||||
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
|
||||
self.assertEqual(pick_list.status, "Partially Transferred")
|
||||
|
||||
def test_get_items_keeps_pick_list_rows_on_stock_entry(self):
|
||||
"""Entering fg_completed_qty on a Stock Entry mapped from a Pick List triggers get_items();
|
||||
it must not refetch from the BOM, or the pick_list_item links transferred_qty rides on are
|
||||
lost and the Pick List stays Open with every row offered again."""
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
create_pick_list as pick_list_for_wo,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import make_work_order
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
source_warehouse = create_warehouse("_Test Partial Transfer Source")
|
||||
wip_warehouse = create_warehouse("_Test Partial Transfer WIP", company="_Test Company")
|
||||
fg_warehouse = create_warehouse("_Test Partial Transfer FG", company="_Test Company")
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
bom = make_bom(item=fg_item, rate=100, raw_materials=[rm_item])
|
||||
make_stock_entry(item=rm_item, to_warehouse=source_warehouse, qty=100)
|
||||
|
||||
wo = make_work_order(item=fg_item, qty=10, bom_no=bom.name, company="_Test Company")
|
||||
wo.required_items[0].source_warehouse = source_warehouse
|
||||
wo.wip_warehouse = wip_warehouse
|
||||
wo.fg_warehouse = fg_warehouse
|
||||
wo.submit()
|
||||
|
||||
pick_list = pick_list_for_wo(wo.name, for_qty=wo.qty)
|
||||
pick_list.save().submit()
|
||||
self.assertEqual(pick_list.status, "Open")
|
||||
|
||||
se = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
|
||||
self.assertTrue(all(row.pick_list_item for row in se.items))
|
||||
self.assertEqual(se.fg_completed_qty, 0)
|
||||
|
||||
se.fg_completed_qty = 4
|
||||
se.get_items()
|
||||
self.assertEqual(len(se.items), len(pick_list.locations))
|
||||
self.assertTrue(all(row.pick_list_item for row in se.items))
|
||||
se.fg_completed_qty = 0
|
||||
|
||||
for row in se.items:
|
||||
row.qty = 4
|
||||
se.save().submit()
|
||||
self.assertEqual(se.fg_completed_qty, 0)
|
||||
|
||||
pick_list.reload()
|
||||
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
|
||||
self.assertEqual(pick_list.status, "Partially Transferred")
|
||||
|
||||
next_se = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
|
||||
self.assertEqual(len(next_se.items), 1)
|
||||
self.assertEqual(next_se.items[0].qty, 6)
|
||||
|
||||
def test_create_second_delivery_note_with_fully_delivered_location(self):
|
||||
# When one pick list item is fully delivered by the first Delivery Note
|
||||
# and another item is still pending, creating a second Delivery Note from
|
||||
|
||||
@@ -365,6 +365,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1301,7 +1302,7 @@
|
||||
"idx": 261,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-27 16:46:30.210628",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Purchase Receipt",
|
||||
|
||||
@@ -18,6 +18,7 @@ from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accoun
|
||||
from erpnext.buying.utils import check_on_hold_or_closed_status
|
||||
from erpnext.controllers.accounts_controller import merge_taxes
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction
|
||||
from erpnext.stock.serial_batch_bundle import (
|
||||
SerialBatchCreation,
|
||||
@@ -1350,6 +1351,8 @@ def make_purchase_invoice(source_name, target_doc=None, args=None):
|
||||
doc = frappe.get_doc("Purchase Receipt", source_name)
|
||||
returned_qty_map = get_returned_qty_map(source_name)
|
||||
invoiced_qty_map = get_invoiced_qty_map(source_name)
|
||||
for ref, qty in get_qty_already_mapped(target_doc, "pr_detail").items():
|
||||
invoiced_qty_map[ref] = invoiced_qty_map.get(ref, 0) + qty
|
||||
|
||||
def set_missing_values(source, target):
|
||||
if len(target.get("items")) == 0:
|
||||
@@ -1434,7 +1437,7 @@ def make_purchase_invoice(source_name, target_doc=None, args=None):
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"filter": lambda d: (
|
||||
get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] > 0
|
||||
get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] >= 0
|
||||
),
|
||||
"condition": select_item,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, call
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import add_days, add_to_date, now, nowdate, today
|
||||
from frappe.utils import add_days, add_to_date, flt, now, nowdate, today
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.utils import repost_gle_for_stock_vouchers
|
||||
@@ -475,6 +475,56 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin):
|
||||
# incoming rate after reposting should be 150
|
||||
self.assertSLEs(se, [{"incoming_rate": 150}])
|
||||
|
||||
def test_recalculate_stock_entry_additional_cost_updates_all_incoming_rows(self):
|
||||
from erpnext.stock.stock_ledger import update_entries_after
|
||||
|
||||
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
|
||||
warehouse = "Stores - TCP1"
|
||||
items = [
|
||||
self.make_item(f"_Test Repost Addl Cost {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, 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()
|
||||
|
||||
first_row = transfer.items[0]
|
||||
frappe.db.set_value("Stock Entry Detail", first_row.name, "basic_rate", first_row.basic_rate + 1)
|
||||
update_entries_after.recalculate_amounts_in_stock_entry(MagicMock(), transfer.name, first_row.name)
|
||||
|
||||
transfer.load_from_db()
|
||||
detail_additional_cost = sum(row.additional_cost for row in transfer.items)
|
||||
net_added_to_stock = sum(row.amount - row.basic_amount for row in transfer.items)
|
||||
|
||||
self.assertEqual(flt(detail_additional_cost, 2), flt(transfer.total_additional_costs, 2))
|
||||
self.assertEqual(flt(net_added_to_stock, 2), flt(transfer.total_additional_costs, 2))
|
||||
|
||||
def test_repost_multi_line_moving_average_return(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
|
||||
@@ -181,6 +181,13 @@ frappe.ui.form.on("Stock Entry", {
|
||||
if (!check_should_not_attach_bom_items(frm.doc.bom_no)) {
|
||||
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
|
||||
}
|
||||
|
||||
if (frm.doc.pick_list) {
|
||||
frm.set_df_property("get_items", "hidden", 1);
|
||||
if (!frm.doc.job_card) {
|
||||
frm.set_df_property("fg_completed_qty", "read_only", 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setup_quality_inspection: function (frm) {
|
||||
@@ -1356,10 +1363,16 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
|
||||
) {
|
||||
frappe.model.remove_from_locals("Work Order", this.frm.doc.work_order);
|
||||
}
|
||||
|
||||
if (this.frm.doc.pick_list) {
|
||||
frappe.model.remove_from_locals("Pick List", this.frm.doc.pick_list);
|
||||
}
|
||||
}
|
||||
|
||||
fg_completed_qty() {
|
||||
this.get_items();
|
||||
if (!this.frm.doc.pick_list) {
|
||||
this.get_items();
|
||||
}
|
||||
}
|
||||
|
||||
get_items() {
|
||||
|
||||
@@ -1205,17 +1205,26 @@ class StockEntry(StockController):
|
||||
if transfer_limit_qty < to_transfer_qty:
|
||||
return
|
||||
|
||||
required_qty, transferred_qty = self._get_work_order_material_qty()
|
||||
self.cap_completed_qty_to_material_coverage()
|
||||
|
||||
def cap_completed_qty_to_material_coverage(self):
|
||||
required_qty, transferred_qty, target_qty, precision = self._get_material_coverage_data()
|
||||
if not required_qty:
|
||||
return
|
||||
|
||||
covered_before = self._get_covered_work_order_qty(required_qty, transferred_qty)
|
||||
covered_before = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
|
||||
for row in self.items:
|
||||
item_code = row.original_item or row.item_code
|
||||
if row.s_warehouse and item_code in required_qty:
|
||||
transferred_qty[item_code] += flt(row.qty) * flt(row.conversion_factor or 1)
|
||||
if self.job_card:
|
||||
material_reference = row.job_card_item
|
||||
transferred = flt(row.qty)
|
||||
else:
|
||||
material_reference = row.original_item or row.item_code
|
||||
transferred = flt(row.qty) * flt(row.conversion_factor or 1)
|
||||
|
||||
covered_after = self._get_covered_work_order_qty(required_qty, transferred_qty)
|
||||
if material_reference in required_qty and (self.job_card or row.s_warehouse):
|
||||
transferred_qty[material_reference] += transferred
|
||||
|
||||
covered_after = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
|
||||
covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty"))
|
||||
self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry)
|
||||
|
||||
@@ -1230,6 +1239,49 @@ class StockEntry(StockController):
|
||||
return False
|
||||
return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card")
|
||||
|
||||
def _get_material_coverage_data(self):
|
||||
if self.job_card:
|
||||
return self._get_job_card_material_qty()
|
||||
return self._get_work_order_material_qty()
|
||||
|
||||
def _get_job_card_material_qty(self):
|
||||
job_card = frappe.get_doc("Job Card", self.job_card)
|
||||
required_qty = {}
|
||||
transferred_qty = {}
|
||||
for row in job_card.items:
|
||||
if flt(row.required_qty) <= 0:
|
||||
continue
|
||||
required_qty[row.name] = flt(row.required_qty)
|
||||
transferred_qty[row.name] = flt(row.transferred_qty)
|
||||
|
||||
return (
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
self._get_job_card_target_qty(job_card),
|
||||
job_card.precision("required_qty", "items"),
|
||||
)
|
||||
|
||||
def _get_job_card_target_qty(self, job_card):
|
||||
required_by_item = {}
|
||||
for row in job_card.items:
|
||||
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
|
||||
work_order_required_by_item = {}
|
||||
work_order = frappe.get_doc("Work Order", job_card.work_order)
|
||||
for row in work_order.required_items:
|
||||
if job_card.operation != row.operation:
|
||||
continue
|
||||
work_order_required_by_item[row.item_code] = work_order_required_by_item.get(
|
||||
row.item_code, 0.0
|
||||
) + flt(row.required_qty)
|
||||
|
||||
target_qty = [
|
||||
item_required * flt(work_order.qty) / work_order_required_by_item[item_code]
|
||||
for item_code, item_required in required_by_item.items()
|
||||
if work_order_required_by_item.get(item_code)
|
||||
]
|
||||
return min(target_qty) if target_qty else job_card.for_quantity
|
||||
|
||||
def _get_work_order_material_qty(self):
|
||||
required_qty = {}
|
||||
transferred_qty = {}
|
||||
@@ -1241,15 +1293,20 @@ class StockEntry(StockController):
|
||||
transferred_qty[row.item_code] = max(
|
||||
transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty)
|
||||
)
|
||||
return required_qty, transferred_qty
|
||||
return (
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
self.pro_doc.qty,
|
||||
self.pro_doc.precision("required_qty", "required_items"),
|
||||
)
|
||||
|
||||
def _get_covered_work_order_qty(self, required_qty, transferred_qty):
|
||||
def _get_covered_qty(self, required_qty, transferred_qty, target_qty, precision):
|
||||
min_fraction = get_minimum_material_coverage_fraction(
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
self.pro_doc.precision("required_qty", "required_items"),
|
||||
precision,
|
||||
)
|
||||
return min_fraction * flt(self.pro_doc.qty)
|
||||
return min_fraction * flt(target_qty)
|
||||
|
||||
def _validate_no_excess_transfer(self):
|
||||
if self.is_return:
|
||||
@@ -2576,6 +2633,9 @@ class StockEntry(StockController):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_items(self):
|
||||
if self.pick_list:
|
||||
return
|
||||
|
||||
self.set("items", [])
|
||||
self.validate_work_order()
|
||||
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.core.doctype.role.role import get_users
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder.functions import Concat_ws, Sum
|
||||
from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
|
||||
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchBundle
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_parsed_serial_nos
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchBundle, get_serial_nos
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
|
||||
|
||||
@@ -30,6 +32,10 @@ class InventoryDimensionNegativeStockError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class SerialNoInventoryDimensionError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
exclude_from_linked_with = True
|
||||
|
||||
|
||||
@@ -98,6 +104,7 @@ class StockLedgerEntry(Document):
|
||||
self.block_transactions_against_group_warehouse()
|
||||
self.validate_with_last_transaction_posting_time()
|
||||
self.validate_inventory_dimension_negative_stock()
|
||||
self.validate_serial_no_inventory_dimension()
|
||||
|
||||
def set_posting_datetime(self):
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
@@ -172,6 +179,87 @@ class StockLedgerEntry(Document):
|
||||
|
||||
return inv_dimension_dict
|
||||
|
||||
def validate_serial_no_inventory_dimension(self):
|
||||
if self.is_cancelled or self.actual_qty >= 0:
|
||||
return
|
||||
|
||||
dimensions = get_inventory_dimensions()
|
||||
if not dimensions:
|
||||
return
|
||||
|
||||
serial_nos = get_serial_nos(self.serial_and_batch_bundle)
|
||||
if not serial_nos and self.serial_no:
|
||||
serial_nos = get_parsed_serial_nos(self.serial_no)
|
||||
|
||||
if not serial_nos:
|
||||
return
|
||||
|
||||
for serial_no, values in self.get_last_inward_dimensions(serial_nos, dimensions).items():
|
||||
mismatches = []
|
||||
for dimension in dimensions:
|
||||
fieldname = dimension.fieldname
|
||||
expected_value = values.get(fieldname)
|
||||
if expected_value != self.get(fieldname):
|
||||
mismatches.append(
|
||||
_('{0}: expected "{1}", got "{2}"').format(
|
||||
dimension.dimension_name,
|
||||
expected_value or _("Not Set"),
|
||||
self.get(fieldname),
|
||||
)
|
||||
)
|
||||
|
||||
if mismatches:
|
||||
frappe.throw(
|
||||
_("Serial No {0} is not available in the selected inventory dimensions: {1}").format(
|
||||
frappe.bold(serial_no), frappe.bold(", ".join(mismatches))
|
||||
),
|
||||
title=_("Incorrect Inventory Dimension"),
|
||||
exc=SerialNoInventoryDimensionError,
|
||||
)
|
||||
|
||||
def get_last_inward_dimensions(self, serial_nos, dimensions):
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
serial_entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
dimension_fields = [sle[dimension.fieldname].as_(dimension.fieldname) for dimension in dimensions]
|
||||
escaped_serial_nos = [re.escape(serial_no) for serial_no in serial_nos]
|
||||
legacy_serial_pattern = r"[\n,][[:space:]]*(" + "|".join(escaped_serial_nos) + r")[[:space:]]*[\n,]"
|
||||
legacy_serial_condition = (
|
||||
sle.serial_and_batch_bundle.isnull() | (sle.serial_and_batch_bundle == "")
|
||||
) & Concat_ws("", "\n", sle.serial_no, "\n").regexp(legacy_serial_pattern)
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(sle)
|
||||
.left_join(serial_entry)
|
||||
.on(serial_entry.parent == sle.serial_and_batch_bundle)
|
||||
.select(
|
||||
serial_entry.serial_no.as_("bundle_serial_no"),
|
||||
sle.serial_no.as_("legacy_serial_nos"),
|
||||
*dimension_fields,
|
||||
)
|
||||
.where(
|
||||
(serial_entry.serial_no.isin(serial_nos) | legacy_serial_condition)
|
||||
& (sle.item_code == self.item_code)
|
||||
& (sle.actual_qty > 0)
|
||||
& (sle.is_cancelled == 0)
|
||||
& (sle.posting_datetime <= self.posting_datetime)
|
||||
)
|
||||
.orderby(sle.posting_datetime, order=frappe.qb.desc)
|
||||
.orderby(sle.creation, order=frappe.qb.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
serial_nos = set(serial_nos)
|
||||
last_inward_dimensions = {}
|
||||
for row in rows:
|
||||
row_serial_nos = (
|
||||
[row.bundle_serial_no]
|
||||
if row.bundle_serial_no
|
||||
else get_parsed_serial_nos(row.legacy_serial_nos)
|
||||
)
|
||||
for serial_no in serial_nos.intersection(row_serial_nos):
|
||||
last_inward_dimensions.setdefault(serial_no, row)
|
||||
|
||||
return last_inward_dimensions
|
||||
|
||||
def on_submit(self):
|
||||
self.check_stock_frozen_date()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.model import child_table_fields, default_fields
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.model.utils import get_fetch_values
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
@@ -34,11 +35,34 @@ purchase_doctypes = [
|
||||
"Purchase Invoice",
|
||||
]
|
||||
|
||||
maintain_same_rate_source_fields = {
|
||||
"Purchase Order": {"supplier_quotation_item": "Supplier Quotation Item"},
|
||||
"Purchase Receipt": {"purchase_order_item": "Purchase Order Item"},
|
||||
"Purchase Invoice": {"po_detail": "Purchase Order Item", "pr_detail": "Purchase Receipt Item"},
|
||||
"Sales Order": {"quotation_item": "Quotation Item"},
|
||||
"Delivery Note": {"so_detail": "Sales Order Item", "si_detail": "Sales Invoice Item"},
|
||||
"Sales Invoice": {"so_detail": "Sales Order Item", "dn_detail": "Delivery Note Item"},
|
||||
}
|
||||
|
||||
LOCKED_RATE_FIELDS = [
|
||||
"price_list_rate",
|
||||
"rate",
|
||||
"discount_percentage",
|
||||
"discount_amount",
|
||||
"margin_type",
|
||||
"margin_rate_or_amount",
|
||||
]
|
||||
|
||||
NOT_APPLICABLE_TAX = "N/A"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True):
|
||||
def get_item_details(
|
||||
args: dict | str,
|
||||
doc: Document | dict | str | None = None,
|
||||
for_validate: bool | str = False,
|
||||
overwrite_warehouse: bool | str = True,
|
||||
):
|
||||
"""
|
||||
args = {
|
||||
"item_code": "",
|
||||
@@ -100,16 +124,20 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
|
||||
if args.get("doctype") in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]:
|
||||
args.customer = None
|
||||
|
||||
out.update(get_price_list_rate(args, item))
|
||||
source_row = get_rate_locked_source_row(args, doc)
|
||||
if source_row:
|
||||
lock_source_rate(out, source_row)
|
||||
else:
|
||||
out.update(get_price_list_rate(args, item))
|
||||
|
||||
if (
|
||||
not out.price_list_rate
|
||||
and args.transaction_type == "selling"
|
||||
and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list")
|
||||
):
|
||||
fallback_args = args.copy()
|
||||
fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list")
|
||||
out.update(get_price_list_rate(fallback_args, item))
|
||||
if (
|
||||
not out.price_list_rate
|
||||
and args.transaction_type == "selling"
|
||||
and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list")
|
||||
):
|
||||
fallback_args = args.copy()
|
||||
fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list")
|
||||
out.update(get_price_list_rate(fallback_args, item))
|
||||
|
||||
args.customer = current_customer
|
||||
|
||||
@@ -124,9 +152,8 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
|
||||
if args.get(key) is None:
|
||||
args[key] = value
|
||||
|
||||
data = get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate)
|
||||
|
||||
out.update(data)
|
||||
if not source_row:
|
||||
out.update(get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate))
|
||||
|
||||
if (
|
||||
frappe.db.get_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward")
|
||||
@@ -156,6 +183,52 @@ def remove_standard_fields(details):
|
||||
return details
|
||||
|
||||
|
||||
def get_rate_locked_source_row(args, doc):
|
||||
"""Reads the source row from the DB, not the mutable target row, so an unsaved edit can't override the locked rate."""
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
|
||||
source_fields = maintain_same_rate_source_fields.get(args.parenttype or args.doctype)
|
||||
if not source_fields or not doc or args.get("is_return") or not maintain_same_rate_enabled(args):
|
||||
return None
|
||||
|
||||
row = next((d for d in doc.get("items") or [] if d.get("name") == args.child_docname), None)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
for link_field, source_doctype in source_fields.items():
|
||||
if source_name := row.get(link_field):
|
||||
# don't leak another document's pricing to a caller without read access
|
||||
source = frappe.db.get_value(
|
||||
source_doctype, source_name, [*LOCKED_RATE_FIELDS, "parent", "parenttype"], as_dict=True
|
||||
)
|
||||
if source and frappe.has_permission(source.parenttype, doc=source.parent):
|
||||
return source
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def maintain_same_rate_enabled(transaction_args):
|
||||
if (transaction_args.parenttype or transaction_args.doctype) in purchase_doctypes:
|
||||
if transaction_args.get("is_internal_supplier"):
|
||||
return False
|
||||
return bool(cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate")))
|
||||
|
||||
if transaction_args.get("is_internal_customer"):
|
||||
return False
|
||||
return bool(cint(frappe.get_cached_value("Selling Settings", "None", "maintain_same_sales_rate")))
|
||||
|
||||
|
||||
def lock_source_rate(out, source_row):
|
||||
"""Copies the full pricing block so a manual discount or margin on the source row survives."""
|
||||
out.price_list_rate = flt(source_row.get("price_list_rate")) or flt(source_row.get("rate"))
|
||||
out.rate = flt(source_row.get("rate"))
|
||||
out.discount_percentage = flt(source_row.get("discount_percentage"))
|
||||
out.discount_amount = flt(source_row.get("discount_amount"))
|
||||
out.margin_type = source_row.get("margin_type")
|
||||
out.margin_rate_or_amount = flt(source_row.get("margin_rate_or_amount"))
|
||||
|
||||
|
||||
def set_valuation_rate(out, args):
|
||||
if frappe.db.exists("Product Bundle", {"name": args.item_code, "disabled": 0}, cache=True):
|
||||
valuation_rate = 0.0
|
||||
@@ -1345,6 +1418,11 @@ def get_pos_profile(company, pos_profile=None, user=None):
|
||||
if not user:
|
||||
user = frappe.session["user"]
|
||||
|
||||
allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name")
|
||||
|
||||
if not allowed_pos_profiles:
|
||||
return None
|
||||
|
||||
pf = frappe.qb.DocType("POS Profile")
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
@@ -1354,6 +1432,7 @@ def get_pos_profile(company, pos_profile=None, user=None):
|
||||
.on(pf.name == pfu.parent)
|
||||
.select(pf.star)
|
||||
.where((pfu.user == user) & (pfu.default == 1))
|
||||
.where(pf.name.isin(allowed_pos_profiles))
|
||||
)
|
||||
|
||||
if company:
|
||||
@@ -1368,6 +1447,7 @@ def get_pos_profile(company, pos_profile=None, user=None):
|
||||
.on(pf.name == pfu.parent)
|
||||
.select(pf.star)
|
||||
.where((pf.company == company) & (pf.disabled == 0))
|
||||
.where(pf.name.isin(allowed_pos_profiles))
|
||||
).run(as_dict=True)
|
||||
|
||||
return pos_profile and pos_profile[0] or None
|
||||
@@ -1522,13 +1602,21 @@ def apply_price_list(args, as_doc=False, doc=None):
|
||||
|
||||
def apply_price_list_on_item(args, doc=None):
|
||||
item_doc = frappe.db.get_value("Item", args.item_code, ["name", "variant_of"], as_dict=1)
|
||||
item_details = get_price_list_rate(args, item_doc)
|
||||
|
||||
source_row = get_rate_locked_source_row(args, doc)
|
||||
if source_row:
|
||||
item_details = frappe._dict()
|
||||
lock_source_rate(item_details, source_row)
|
||||
else:
|
||||
item_details = get_price_list_rate(args, item_doc)
|
||||
|
||||
args.conversion_factor = flt(args.conversion_factor) or get_conversion_factor(
|
||||
args.item_code, args.uom
|
||||
).get("conversion_factor", 1)
|
||||
args.stock_qty = flt(args.qty) * flt(args.conversion_factor)
|
||||
item_details.update(get_pricing_rule_for_item(args, doc=doc))
|
||||
|
||||
if not source_row:
|
||||
item_details.update(get_pricing_rule_for_item(args, doc=doc))
|
||||
|
||||
return item_details
|
||||
|
||||
|
||||
@@ -77,4 +77,4 @@ frappe.query_reports["Available Serial No"] = {
|
||||
},
|
||||
};
|
||||
|
||||
erpnext.utils.add_inventory_dimensions("Balance Serial No", 10);
|
||||
erpnext.utils.add_inventory_dimensions("Available Serial No", 10);
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"json": "{}",
|
||||
"letter_head": "Test",
|
||||
"letter_head": null,
|
||||
"letterhead": null,
|
||||
"modified": "2025-02-03 15:39:47.613040",
|
||||
"modified": "2026-08-26 14:39:19.102191",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Incorrect Serial and Batch Bundle",
|
||||
|
||||
@@ -1406,12 +1406,14 @@ class update_entries_after:
|
||||
stock_entry = frappe.get_doc("Stock Entry", voucher_no, for_update=True)
|
||||
stock_entry.calculate_rate_and_amount(reset_outgoing_rate=False, raise_error_if_no_rate=False)
|
||||
stock_entry.db_update()
|
||||
update_additional_cost_rows = bool(stock_entry.get("additional_costs"))
|
||||
for d in stock_entry.items:
|
||||
# Update only the row that matches the voucher_detail_no or the row containing the FG/Scrap Item.
|
||||
# Additional costs are redistributed across all incoming rows.
|
||||
if (
|
||||
d.name == voucher_detail_no
|
||||
or (not d.s_warehouse and d.t_warehouse)
|
||||
or stock_entry.purpose in ["Manufacture", "Repack"]
|
||||
or (update_additional_cost_rows and d.t_warehouse)
|
||||
):
|
||||
d.db_update()
|
||||
|
||||
@@ -1750,6 +1752,30 @@ class update_entries_after:
|
||||
|
||||
frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True)
|
||||
|
||||
self.reset_bin_without_stock_ledger_entries()
|
||||
|
||||
def reset_bin_without_stock_ledger_entries(self):
|
||||
"""Reset the bin when its ledger has no entries left, a repost never covers that case."""
|
||||
item_code, warehouse = self.args.get("item_code"), self.args.get("warehouse")
|
||||
if not item_code or not warehouse or (item_code, warehouse) in self.prev_sle_dict:
|
||||
return
|
||||
|
||||
if frappe.db.exists(
|
||||
"Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}
|
||||
):
|
||||
return
|
||||
|
||||
bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
if not bin_name:
|
||||
return
|
||||
|
||||
frappe.db.set_value(
|
||||
"Bin",
|
||||
bin_name,
|
||||
{"actual_qty": 0.0, "stock_value": 0.0, "valuation_rate": 0.0},
|
||||
update_modified=True,
|
||||
)
|
||||
|
||||
|
||||
def get_sle_against_current_voucher(kwargs):
|
||||
kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time)
|
||||
@@ -1866,9 +1892,6 @@ def get_stock_ledger_entries(
|
||||
else:
|
||||
conditions += " and warehouse = %(warehouse)s"
|
||||
|
||||
elif previous_sle.get("warehouse_condition"):
|
||||
conditions += " and " + previous_sle.get("warehouse_condition")
|
||||
|
||||
if check_serial_no and previous_sle.get("serial_no"):
|
||||
# conditions += " and serial_no like {}".format(frappe.db.escape('%{0}%'.format(previous_sle.get("serial_no"))))
|
||||
serial_no = previous_sle.get("serial_no")
|
||||
|
||||
@@ -177,3 +177,248 @@ class TestGetItemDetail(FrappeTestCase):
|
||||
dn.save()
|
||||
self.assertEqual(dn.items[0].batch_no, "BATCH01")
|
||||
self.assertEqual(dn.items[0].rate, 50)
|
||||
|
||||
def test_maintain_same_rate_keeps_source_rate_on_refetch(self):
|
||||
"""#57436: with "maintain same rate" on, re-fetching a PR row mapped from a
|
||||
PO must keep the PO rate instead of pulling a newer, higher Item Price.
|
||||
|
||||
The rate is validated on save, so it can never persist changed; assert the
|
||||
fetched rate directly to prove the newer Item Price is never picked up.
|
||||
"""
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
def set_maintain_same_rate(value):
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", value)
|
||||
frappe.clear_cache(doctype="Buying Settings")
|
||||
|
||||
set_maintain_same_rate(1)
|
||||
|
||||
item_code = make_item(properties={"is_stock_item": 1}).name
|
||||
po = create_purchase_order(item_code=item_code, qty=1, rate=100)
|
||||
|
||||
# The PO may auto-insert an Item Price at 100; bump it to the newer, higher rate.
|
||||
item_price = frappe.db.get_value(
|
||||
"Item Price", {"item_code": item_code, "price_list": "Standard Buying"}
|
||||
)
|
||||
if item_price:
|
||||
frappe.db.set_value("Item Price", item_price, "price_list_rate", 120)
|
||||
else:
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Price",
|
||||
"price_list": "Standard Buying",
|
||||
"item_code": item_code,
|
||||
"price_list_rate": 120,
|
||||
}
|
||||
).insert()
|
||||
|
||||
pr = make_purchase_receipt(po.name)
|
||||
pr.insert()
|
||||
|
||||
def fetch_price_list_rate():
|
||||
args = frappe._dict(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"doctype": "Purchase Receipt",
|
||||
"name": pr.name,
|
||||
"company": pr.company,
|
||||
"supplier": pr.supplier,
|
||||
"currency": pr.currency,
|
||||
"conversion_rate": 1.0,
|
||||
"price_list": "Standard Buying",
|
||||
"price_list_currency": pr.currency,
|
||||
"plc_conversion_rate": 1.0,
|
||||
"warehouse": pr.items[0].warehouse,
|
||||
"uom": pr.items[0].uom,
|
||||
"stock_uom": pr.items[0].stock_uom,
|
||||
"qty": pr.items[0].qty,
|
||||
"child_doctype": pr.items[0].doctype,
|
||||
"child_docname": pr.items[0].name,
|
||||
"is_return": 0,
|
||||
"is_internal_supplier": 0,
|
||||
"ignore_pricing_rule": 1,
|
||||
}
|
||||
)
|
||||
return get_item_details(args, pr).get("price_list_rate")
|
||||
|
||||
# Rate stays at the PO rate; the newer Item Price (120) is not fetched.
|
||||
self.assertEqual(fetch_price_list_rate(), 100)
|
||||
|
||||
# Control: without the setting the newer Item Price would be fetched.
|
||||
set_maintain_same_rate(0)
|
||||
self.assertEqual(fetch_price_list_rate(), 120)
|
||||
|
||||
def test_apply_price_list_keeps_source_rate_when_maintain_same_rate(self):
|
||||
"""#57436: the bulk apply_price_list path (price list / party / conversion rate
|
||||
change) must also keep the source rate on mapped rows, not just re-fetch of a
|
||||
single row. Here a PR row carries its PO rate (175) while the current price list
|
||||
rate is 100; the bulk apply must keep 175.
|
||||
"""
|
||||
from frappe.utils import flt, nowdate
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.stock.get_item_details import apply_price_list
|
||||
|
||||
item_code = "_Test Item"
|
||||
price_list = "_Test Buying Price List"
|
||||
|
||||
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
|
||||
frappe.clear_cache(doctype="Buying Settings")
|
||||
|
||||
try:
|
||||
po = create_purchase_order(item_code=item_code, rate=175, qty=1)
|
||||
|
||||
row_name = "pr-row-1"
|
||||
pr_doc = {
|
||||
"doctype": "Purchase Receipt",
|
||||
"items": [
|
||||
{
|
||||
"name": row_name,
|
||||
"item_code": item_code,
|
||||
"purchase_order_item": po.items[0].name,
|
||||
"price_list_rate": 175,
|
||||
"rate": 175,
|
||||
}
|
||||
],
|
||||
}
|
||||
args = frappe._dict(
|
||||
doctype="Purchase Receipt",
|
||||
supplier=po.supplier,
|
||||
company=po.company,
|
||||
currency=po.currency,
|
||||
conversion_rate=1.0,
|
||||
price_list=price_list,
|
||||
plc_conversion_rate=1.0,
|
||||
transaction_date=nowdate(),
|
||||
items=[
|
||||
frappe._dict(
|
||||
doctype="Purchase Receipt Item",
|
||||
parenttype="Purchase Receipt",
|
||||
item_code=item_code,
|
||||
child_docname=row_name,
|
||||
qty=1,
|
||||
uom=po.items[0].uom,
|
||||
stock_uom=po.items[0].stock_uom,
|
||||
conversion_factor=1.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = apply_price_list(args, doc=pr_doc)
|
||||
self.assertEqual(flt(result["children"][0].get("price_list_rate")), 175)
|
||||
finally:
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
|
||||
frappe.clear_cache(doctype="Buying Settings")
|
||||
|
||||
def test_maintain_same_rate_keeps_source_discount_on_refetch(self):
|
||||
"""A mapped source row with a discount has rate != price_list_rate. Re-fetch must
|
||||
return the source's rate and discount, not just the pre-discount price, or the
|
||||
recomputed rate diverges from the reference and fails maintain-same-rate on save.
|
||||
"""
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
|
||||
item_code = "_Test Item"
|
||||
price_list = "_Test Buying Price List"
|
||||
|
||||
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
|
||||
frappe.clear_cache(doctype="Buying Settings")
|
||||
|
||||
try:
|
||||
# source PO carries the discount: list rate 100, 10% off, effective rate 90
|
||||
frappe.flags.dont_fetch_price_list_rate = True
|
||||
po = create_purchase_order(item_code=item_code, qty=1, do_not_save=True)
|
||||
po.buying_price_list = price_list
|
||||
po.items[0].price_list_rate = 100
|
||||
po.items[0].discount_percentage = 10
|
||||
po.items[0].rate = 90
|
||||
po.insert()
|
||||
po.submit()
|
||||
frappe.flags.dont_fetch_price_list_rate = False
|
||||
|
||||
row_name = "pr-row-1"
|
||||
pr_doc = {
|
||||
"doctype": "Purchase Receipt",
|
||||
"items": [
|
||||
{"name": row_name, "item_code": item_code, "purchase_order_item": po.items[0].name}
|
||||
],
|
||||
}
|
||||
args = frappe._dict(
|
||||
item_code=item_code,
|
||||
doctype="Purchase Receipt",
|
||||
company=po.company,
|
||||
supplier=po.supplier,
|
||||
currency=po.currency,
|
||||
conversion_rate=1.0,
|
||||
price_list=price_list,
|
||||
price_list_currency=po.currency,
|
||||
plc_conversion_rate=1.0,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
uom=po.items[0].uom,
|
||||
stock_uom=po.items[0].stock_uom,
|
||||
qty=1,
|
||||
child_docname=row_name,
|
||||
is_return=0,
|
||||
is_internal_supplier=0,
|
||||
ignore_pricing_rule=1,
|
||||
)
|
||||
|
||||
out = get_item_details(args, pr_doc)
|
||||
self.assertEqual(flt(out.get("price_list_rate")), 100)
|
||||
self.assertEqual(flt(out.get("rate")), 90)
|
||||
self.assertEqual(flt(out.get("discount_percentage")), 10)
|
||||
finally:
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
|
||||
frappe.clear_cache(doctype="Buying Settings")
|
||||
frappe.flags.dont_fetch_price_list_rate = False
|
||||
|
||||
def test_rate_lock_source_lookup_checks_permission(self):
|
||||
"""The lock reads source pricing via a direct DB read, so it must not disclose a
|
||||
source document's pricing to a caller who cannot read that document.
|
||||
"""
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.stock.get_item_details import get_rate_locked_source_row
|
||||
|
||||
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
|
||||
frappe.clear_cache(doctype="Buying Settings")
|
||||
|
||||
role, email = "_Test Role Without PO Access", "_test_rate_lock_probe@example.com"
|
||||
try:
|
||||
po = create_purchase_order(item_code="_Test Item", qty=1, rate=90)
|
||||
pr_doc = {
|
||||
"doctype": "Purchase Receipt",
|
||||
"items": [{"name": "r1", "item_code": "_Test Item", "purchase_order_item": po.items[0].name}],
|
||||
}
|
||||
args = frappe._dict(doctype="Purchase Receipt", child_docname="r1")
|
||||
|
||||
# an authorized caller receives the source row
|
||||
self.assertIsNotNone(get_rate_locked_source_row(args.copy(), dict(pr_doc)))
|
||||
|
||||
if not frappe.db.exists("Role", role):
|
||||
frappe.get_doc({"doctype": "Role", "role_name": role, "desk_access": 1}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
if not frappe.db.exists("User", email):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": email,
|
||||
"first_name": "Probe",
|
||||
"send_welcome_email": 0,
|
||||
"roles": [{"role": role}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.set_user(email)
|
||||
# a caller who cannot read the Purchase Order gets nothing
|
||||
self.assertIsNone(get_rate_locked_source_row(args.copy(), dict(pr_doc)))
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
|
||||
frappe.clear_cache(doctype="Buying Settings")
|
||||
|
||||
@@ -50,6 +50,7 @@ def transaction_processing(data, from_doctype, to_doctype):
|
||||
|
||||
@frappe.whitelist()
|
||||
def retry(date: str | None = None):
|
||||
frappe.only_for("System Manager")
|
||||
if not date:
|
||||
date = today()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user