mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-13 20:05:09 +00:00
Merge branch 'version-16-hotfix' into pr-52968
This commit is contained in:
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.3.0"
|
||||
__version__ = "16.7.3"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
"description": "Payment Terms from orders will be fetched into the invoices as is",
|
||||
"fieldname": "automatically_fetch_payment_terms",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Fetch Payment Terms from Order"
|
||||
"label": "Automatically Fetch Payment Terms from Order/Quotation"
|
||||
},
|
||||
{
|
||||
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
|
||||
@@ -697,7 +697,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-04 17:15:38.609327",
|
||||
"modified": "2026-02-27 01:04:09.415288",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -1065,8 +1065,12 @@ class PaymentEntry(AccountsController):
|
||||
total_allocated_amount += flt(d.allocated_amount)
|
||||
base_total_allocated_amount += self.calculate_base_allocated_amount_for_reference(d)
|
||||
|
||||
self.total_allocated_amount = abs(total_allocated_amount)
|
||||
self.base_total_allocated_amount = abs(base_total_allocated_amount)
|
||||
self.total_allocated_amount = flt(
|
||||
abs(total_allocated_amount), self.precision("total_allocated_amount")
|
||||
)
|
||||
self.base_total_allocated_amount = flt(
|
||||
abs(base_total_allocated_amount), self.precision("base_total_allocated_amount")
|
||||
)
|
||||
|
||||
def set_unallocated_amount(self):
|
||||
self.unallocated_amount = 0
|
||||
|
||||
@@ -4,19 +4,6 @@
|
||||
frappe.ui.form.on("POS Closing Entry", {
|
||||
onload: async function (frm) {
|
||||
frm.ignore_doctypes_on_cancel_all = ["POS Invoice Merge Log", "Sales Invoice"];
|
||||
frm.set_query("pos_profile", function (doc) {
|
||||
return {
|
||||
filters: { user: doc.user },
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("user", function (doc) {
|
||||
return {
|
||||
query: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_cashiers",
|
||||
filters: { parent: doc.pos_profile },
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("pos_opening_entry", function (doc) {
|
||||
return { filters: { status: "Open", docstatus: 1 } };
|
||||
});
|
||||
|
||||
@@ -346,8 +346,7 @@ def apply_pricing_rule(args, doc=None):
|
||||
|
||||
args = frappe._dict(args)
|
||||
|
||||
if not args.transaction_type:
|
||||
set_transaction_type(args)
|
||||
set_transaction_type(args)
|
||||
|
||||
# list of dictionaries
|
||||
out = []
|
||||
@@ -683,23 +682,23 @@ def remove_pricing_rules(item_list):
|
||||
return out
|
||||
|
||||
|
||||
def set_transaction_type(args):
|
||||
if args.transaction_type:
|
||||
def set_transaction_type(pricing_ctx: frappe._dict) -> None:
|
||||
if pricing_ctx.transaction_type in ["buying", "selling"]:
|
||||
return
|
||||
if args.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"):
|
||||
args.transaction_type = "selling"
|
||||
elif args.doctype in (
|
||||
if pricing_ctx.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"):
|
||||
pricing_ctx.transaction_type = "selling"
|
||||
elif pricing_ctx.doctype in (
|
||||
"Material Request",
|
||||
"Supplier Quotation",
|
||||
"Purchase Order",
|
||||
"Purchase Receipt",
|
||||
"Purchase Invoice",
|
||||
):
|
||||
args.transaction_type = "buying"
|
||||
elif args.customer:
|
||||
args.transaction_type = "selling"
|
||||
pricing_ctx.transaction_type = "buying"
|
||||
elif pricing_ctx.customer:
|
||||
pricing_ctx.transaction_type = "selling"
|
||||
else:
|
||||
args.transaction_type = "buying"
|
||||
pricing_ctx.transaction_type = "buying"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -800,8 +800,7 @@
|
||||
"hide_seconds": 1,
|
||||
"label": "Time Sheets",
|
||||
"options": "Sales Invoice Timesheet",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -2331,7 +2330,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-25 12:41:57.043459",
|
||||
"modified": "2026-02-28 17:58:56.453076",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -1451,6 +1451,9 @@ class SalesInvoice(SellingController):
|
||||
return asset_qty_map
|
||||
|
||||
def process_asset_depreciation(self):
|
||||
if self.is_internal_transfer():
|
||||
return
|
||||
|
||||
if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1):
|
||||
self.depreciate_asset_on_sale()
|
||||
else:
|
||||
|
||||
@@ -8,6 +8,8 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_default_address
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import cstr
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
@@ -83,6 +85,8 @@ class TaxRule(Document):
|
||||
frappe.throw(_("Tax Template is mandatory."))
|
||||
|
||||
def validate_filters(self):
|
||||
TaxRule = DocType("Tax Rule")
|
||||
|
||||
filters = {
|
||||
"tax_type": self.tax_type,
|
||||
"customer": self.customer,
|
||||
@@ -105,33 +109,34 @@ class TaxRule(Document):
|
||||
"company": self.company,
|
||||
}
|
||||
|
||||
conds = ""
|
||||
for d in filters:
|
||||
if conds:
|
||||
conds += " and "
|
||||
conds += f"""ifnull({d}, '') = {frappe.db.escape(cstr(filters[d]))}"""
|
||||
|
||||
if self.from_date and self.to_date:
|
||||
conds += f""" and ((from_date > '{self.from_date}' and from_date < '{self.to_date}') or
|
||||
(to_date > '{self.from_date}' and to_date < '{self.to_date}') or
|
||||
('{self.from_date}' > from_date and '{self.from_date}' < to_date) or
|
||||
('{self.from_date}' = from_date and '{self.to_date}' = to_date))"""
|
||||
|
||||
elif self.from_date and not self.to_date:
|
||||
conds += f""" and to_date > '{self.from_date}'"""
|
||||
|
||||
elif self.to_date and not self.from_date:
|
||||
conds += f""" and from_date < '{self.to_date}'"""
|
||||
|
||||
tax_rule = frappe.db.sql(
|
||||
f"select name, priority \
|
||||
from `tabTax Rule` where {conds} and name != '{self.name}'",
|
||||
as_dict=1,
|
||||
query = (
|
||||
frappe.qb.from_(TaxRule).select(TaxRule.name, TaxRule.priority).where(TaxRule.name != self.name)
|
||||
)
|
||||
|
||||
if tax_rule:
|
||||
if tax_rule[0].priority == self.priority:
|
||||
frappe.throw(_("Tax Rule Conflicts with {0}").format(tax_rule[0].name), ConflictingTaxRule)
|
||||
for field, value in filters.items():
|
||||
query = query.where(IfNull(TaxRule[field], "") == cstr(value))
|
||||
|
||||
if self.from_date and self.to_date:
|
||||
query = query.where(
|
||||
((TaxRule.from_date > self.from_date) & (TaxRule.from_date < self.to_date))
|
||||
| ((TaxRule.to_date > self.from_date) & (TaxRule.to_date < self.to_date))
|
||||
| ((self.from_date > TaxRule.from_date) & (self.from_date < TaxRule.to_date))
|
||||
| ((TaxRule.from_date == self.from_date) & (TaxRule.to_date == self.to_date))
|
||||
)
|
||||
|
||||
elif self.from_date:
|
||||
query = query.where(TaxRule.to_date > self.from_date)
|
||||
|
||||
elif self.to_date:
|
||||
query = query.where(TaxRule.from_date < self.to_date)
|
||||
|
||||
tax_rule = query.run(as_dict=True)
|
||||
|
||||
if tax_rule and tax_rule[0].priority == self.priority:
|
||||
frappe.throw(
|
||||
_("Tax Rule Conflicts with {0}").format(tax_rule[0].name),
|
||||
ConflictingTaxRule,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils import today
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.report.accounts_payable.accounts_payable import execute
|
||||
@@ -57,3 +57,66 @@ class TestAccountsPayable(AccountsTestMixin, IntegrationTestCase):
|
||||
if not do_not_submit:
|
||||
pi = pi.submit()
|
||||
return pi
|
||||
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
payment_term1 = frappe.get_doc(
|
||||
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"}
|
||||
).insert()
|
||||
payment_term2 = frappe.get_doc(
|
||||
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"}
|
||||
).insert()
|
||||
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Payment Terms Template",
|
||||
"template_name": "_Test 50-50",
|
||||
"terms": [
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term": payment_term1.name,
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 15,
|
||||
},
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term": payment_term2.name,
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 30,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
template.insert()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"based_on_payment_terms": 1,
|
||||
"payment_terms_template": template.name,
|
||||
"ageing_based_on": "Posting Date",
|
||||
}
|
||||
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.payment_terms_template = template.name
|
||||
schedule = get_payment_terms(template.name)
|
||||
pi.set("payment_schedule", [])
|
||||
|
||||
for row in schedule:
|
||||
row["due_date"] = add_days(pi.posting_date, row.get("credit_days", 0))
|
||||
pi.append("payment_schedule", row)
|
||||
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
report = execute(filters)
|
||||
row = report[1][0]
|
||||
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
|
||||
@@ -1035,9 +1035,8 @@ class ReceivablePayableReport:
|
||||
self,
|
||||
):
|
||||
self.customer = qb.DocType("Customer")
|
||||
|
||||
if self.filters.get("customer_group"):
|
||||
groups = get_customer_group_with_children(self.filters.customer_group)
|
||||
groups = get_party_group_with_children("Customer", self.filters.customer_group)
|
||||
customers = (
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
@@ -1049,14 +1048,18 @@ class ReceivablePayableReport:
|
||||
self.get_hierarchical_filters("Territory", "territory")
|
||||
|
||||
if self.filters.get("payment_terms_template"):
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
.where(self.customer.payment_terms == self.filters.get("payment_terms_template"))
|
||||
)
|
||||
customer_ptt = self.ple.party.isin(
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
.where(self.customer.payment_terms == self.filters.get("payment_terms_template"))
|
||||
)
|
||||
|
||||
si_ptt = self.add_payment_term_template_filters("Sales Invoice")
|
||||
|
||||
sales_ptt = self.ple.against_voucher_no.isin(si_ptt)
|
||||
|
||||
self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt]))
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
@@ -1081,14 +1084,53 @@ class ReceivablePayableReport:
|
||||
)
|
||||
|
||||
if self.filters.get("payment_terms_template"):
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
qb.from_(supplier)
|
||||
.select(supplier.name)
|
||||
.where(supplier.payment_terms == self.filters.get("supplier_group"))
|
||||
)
|
||||
supplier_ptt = self.ple.party.isin(
|
||||
qb.from_(supplier)
|
||||
.select(supplier.name)
|
||||
.where(supplier.payment_terms == self.filters.get("payment_terms_template"))
|
||||
)
|
||||
|
||||
pi_ptt = self.add_payment_term_template_filters("Purchase Invoice")
|
||||
|
||||
purchase_ptt = self.ple.against_voucher_no.isin(pi_ptt)
|
||||
|
||||
self.qb_selection_filter.append(Criterion.any([supplier_ptt, purchase_ptt]))
|
||||
|
||||
def add_payment_term_template_filters(self, dtype):
|
||||
voucher_type = qb.DocType(dtype)
|
||||
|
||||
ptt = (
|
||||
qb.from_(voucher_type)
|
||||
.select(voucher_type.name)
|
||||
.where(voucher_type.payment_terms_template == self.filters.get("payment_terms_template"))
|
||||
.where(voucher_type.company == self.filters.company)
|
||||
)
|
||||
|
||||
if dtype == "Purchase Invoice":
|
||||
party = "Supplier"
|
||||
party_group_type = "supplier_group"
|
||||
acc_type = "credit_to"
|
||||
else:
|
||||
party = "Customer"
|
||||
party_group_type = "customer_group"
|
||||
acc_type = "debit_to"
|
||||
|
||||
if self.filters.get(party_group_type):
|
||||
party_groups = get_party_group_with_children(party, self.filters.get(party_group_type))
|
||||
ptt = ptt.where((voucher_type[party_group_type]).isin(party_groups))
|
||||
|
||||
if self.filters.party:
|
||||
ptt = ptt.where((voucher_type[party.lower()]).isin(self.filters.party))
|
||||
|
||||
if self.filters.cost_center:
|
||||
cost_centers = get_cost_centers_with_children(self.filters.cost_center)
|
||||
ptt = ptt.where(voucher_type.cost_center.isin(cost_centers))
|
||||
|
||||
if self.filters.party_account:
|
||||
ptt = ptt.where(voucher_type[acc_type] == self.filters.party_account)
|
||||
|
||||
return ptt
|
||||
|
||||
def get_hierarchical_filters(self, doctype, key):
|
||||
lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"])
|
||||
|
||||
@@ -1330,20 +1372,26 @@ class ReceivablePayableReport:
|
||||
self.err_journals = [x[0] for x in results] if results else []
|
||||
|
||||
|
||||
def get_customer_group_with_children(customer_groups):
|
||||
if not isinstance(customer_groups, list):
|
||||
customer_groups = [d.strip() for d in customer_groups.strip().split(",") if d]
|
||||
def get_party_group_with_children(party, party_groups):
|
||||
if party not in ("Customer", "Supplier"):
|
||||
return []
|
||||
|
||||
all_customer_groups = []
|
||||
for d in customer_groups:
|
||||
if frappe.db.exists("Customer Group", d):
|
||||
lft, rgt = frappe.db.get_value("Customer Group", d, ["lft", "rgt"])
|
||||
children = frappe.get_all("Customer Group", filters={"lft": [">=", lft], "rgt": ["<=", rgt]})
|
||||
all_customer_groups += [c.name for c in children]
|
||||
group_dtype = f"{party} Group"
|
||||
if not isinstance(party_groups, list):
|
||||
party_groups = [d.strip() for d in party_groups.strip().split(",") if d]
|
||||
|
||||
all_party_groups = []
|
||||
for d in party_groups:
|
||||
if frappe.db.exists(group_dtype, d):
|
||||
lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"])
|
||||
children = frappe.get_all(
|
||||
group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name"
|
||||
)
|
||||
all_party_groups += children
|
||||
else:
|
||||
frappe.throw(_("Customer Group: {0} does not exist").format(d))
|
||||
frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d))
|
||||
|
||||
return list(set(all_customer_groups))
|
||||
return list(set(all_party_groups))
|
||||
|
||||
|
||||
class InitSQLProceduresForAR:
|
||||
|
||||
@@ -1139,3 +1139,66 @@ class TestAccountsReceivable(AccountsTestMixin, IntegrationTestCase):
|
||||
self.assertEqual(len(report[1]), 1)
|
||||
row = report[1][0]
|
||||
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
|
||||
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
payment_term1 = frappe.get_doc(
|
||||
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"}
|
||||
).insert()
|
||||
payment_term2 = frappe.get_doc(
|
||||
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"}
|
||||
).insert()
|
||||
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Payment Terms Template",
|
||||
"template_name": "_Test 50-50",
|
||||
"terms": [
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term": payment_term1.name,
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 15,
|
||||
},
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term": payment_term2.name,
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 30,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
template.insert()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"based_on_payment_terms": 1,
|
||||
"payment_terms_template": template.name,
|
||||
"ageing_based_on": "Posting Date",
|
||||
}
|
||||
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.payment_terms_template = template.name
|
||||
schedule = get_payment_terms(template.name)
|
||||
si.set("payment_schedule", [])
|
||||
|
||||
for row in schedule:
|
||||
row["due_date"] = add_days(si.posting_date, row.get("credit_days", 0))
|
||||
si.append("payment_schedule", row)
|
||||
|
||||
si.save()
|
||||
si.submit()
|
||||
|
||||
report = execute(filters)
|
||||
row = report[1][0]
|
||||
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([si.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
|
||||
@@ -5,6 +5,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import add_months, flt, formatdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.controllers.trends import get_period_date_ranges
|
||||
|
||||
@@ -13,6 +14,8 @@ def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
validate_filters(filters)
|
||||
|
||||
columns = get_columns(filters)
|
||||
if filters.get("budget_against_filter"):
|
||||
dimensions = filters.get("budget_against_filter")
|
||||
@@ -31,6 +34,10 @@ def execute(filters=None):
|
||||
return columns, data, None, chart_data
|
||||
|
||||
|
||||
def validate_filters(filters):
|
||||
validate_budget_dimensions(filters)
|
||||
|
||||
|
||||
def get_budget_records(filters, dimensions):
|
||||
budget_against_field = frappe.scrub(filters["budget_against"])
|
||||
|
||||
@@ -51,7 +58,7 @@ def get_budget_records(filters, dimensions):
|
||||
b.company = %s
|
||||
AND b.docstatus = 1
|
||||
AND b.budget_against = %s
|
||||
AND b.{budget_against_field} IN ({', '.join(['%s'] * len(dimensions))})
|
||||
AND b.{budget_against_field} IN ({", ".join(["%s"] * len(dimensions))})
|
||||
AND (
|
||||
b.from_fiscal_year <= %s
|
||||
AND b.to_fiscal_year >= %s
|
||||
@@ -404,6 +411,17 @@ def get_budget_dimensions(filters):
|
||||
) # nosec
|
||||
|
||||
|
||||
def validate_budget_dimensions(filters):
|
||||
dimensions = [d.get("document_type") for d in get_dimensions(with_cost_center_and_project=True)[0]]
|
||||
if filters.get("budget_against") and filters.get("budget_against") not in dimensions:
|
||||
frappe.throw(
|
||||
title=_("Invalid Accounting Dimension"),
|
||||
msg=_("{0} is not a valid Accounting Dimension.").format(
|
||||
frappe.bold(filters.get("budget_against"))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_comparison_chart_data(filters, columns, data):
|
||||
if not data:
|
||||
return None
|
||||
|
||||
@@ -86,6 +86,12 @@ frappe.query_reports["Consolidated Trial Balance"] = {
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "show_net_values",
|
||||
label: __("Show net values in opening and closing columns"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "show_group_accounts",
|
||||
label: __("Show Group Accounts"),
|
||||
|
||||
@@ -14,6 +14,7 @@ from erpnext.accounts.report.financial_statements import (
|
||||
)
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import (
|
||||
accumulate_values_into_parents,
|
||||
calculate_total_row,
|
||||
calculate_values,
|
||||
get_opening_balances,
|
||||
hide_group_accounts,
|
||||
@@ -44,7 +45,6 @@ def execute(filters: dict | None = None):
|
||||
|
||||
def validate_filters(filters):
|
||||
validate_companies(filters)
|
||||
filters.show_net_values = True
|
||||
tb_validate_filters(filters)
|
||||
|
||||
|
||||
@@ -99,16 +99,20 @@ def get_data(filters) -> list[list]:
|
||||
tb_data = get_company_wise_tb_data(company_filter, reporting_currency, ignore_reporting_currency)
|
||||
consolidate_trial_balance_data(data, tb_data)
|
||||
|
||||
for d in data:
|
||||
prepare_opening_closing(d)
|
||||
|
||||
total_row = calculate_total_row(data, reporting_currency)
|
||||
|
||||
data.extend([{}, total_row])
|
||||
if filters.get("show_net_values"):
|
||||
prepare_opening_closing_for_ctb(data)
|
||||
|
||||
if not filters.get("show_group_accounts"):
|
||||
data = hide_group_accounts(data)
|
||||
|
||||
total_row = calculate_total_row(
|
||||
data, reporting_currency, show_group_accounts=filters.get("show_group_accounts")
|
||||
)
|
||||
|
||||
calculate_foreign_currency_translation_reserve(total_row, data, filters=filters)
|
||||
|
||||
data.extend([total_row])
|
||||
|
||||
if filters.get("presentation_currency"):
|
||||
update_to_presentation_currency(
|
||||
data,
|
||||
@@ -207,10 +211,6 @@ def prepare_companywise_tb_data(accounts, filters, parent_children_map, reportin
|
||||
data = []
|
||||
|
||||
for d in accounts:
|
||||
# Prepare opening closing for group account
|
||||
if parent_children_map.get(d.account) and filters.get("show_net_values"):
|
||||
prepare_opening_closing(d)
|
||||
|
||||
has_value = False
|
||||
row = {
|
||||
"account": d.name,
|
||||
@@ -242,35 +242,9 @@ def prepare_companywise_tb_data(accounts, filters, parent_children_map, reportin
|
||||
return data
|
||||
|
||||
|
||||
def calculate_total_row(data, reporting_currency):
|
||||
total_row = {
|
||||
"account": "'" + _("Total") + "'",
|
||||
"account_name": "'" + _("Total") + "'",
|
||||
"warn_if_negative": True,
|
||||
"opening_debit": 0.0,
|
||||
"opening_credit": 0.0,
|
||||
"debit": 0.0,
|
||||
"credit": 0.0,
|
||||
"closing_debit": 0.0,
|
||||
"closing_credit": 0.0,
|
||||
"parent_account": None,
|
||||
"indent": 0,
|
||||
"has_value": True,
|
||||
"currency": reporting_currency,
|
||||
}
|
||||
|
||||
for d in data:
|
||||
if not d.get("parent_account"):
|
||||
for field in value_fields:
|
||||
total_row[field] += d[field]
|
||||
|
||||
if data:
|
||||
calculate_foreign_currency_translation_reserve(total_row, data)
|
||||
|
||||
return total_row
|
||||
|
||||
|
||||
def calculate_foreign_currency_translation_reserve(total_row, data):
|
||||
def calculate_foreign_currency_translation_reserve(total_row, data, filters):
|
||||
if not data or not total_row:
|
||||
return
|
||||
opening_dr_cr_diff = total_row["opening_debit"] - total_row["opening_credit"]
|
||||
dr_cr_diff = total_row["debit"] - total_row["credit"]
|
||||
|
||||
@@ -289,7 +263,7 @@ def calculate_foreign_currency_translation_reserve(total_row, data):
|
||||
"root_type": data[idx].get("root_type"),
|
||||
"account_type": "Equity",
|
||||
"parent_account": data[idx].get("account"),
|
||||
"indent": data[idx].get("indent") + 1,
|
||||
"indent": data[idx].get("indent") + 1 if filters.get("show_group_accounts") else 0,
|
||||
"has_value": True,
|
||||
"currency": total_row.get("currency"),
|
||||
}
|
||||
@@ -297,7 +271,8 @@ def calculate_foreign_currency_translation_reserve(total_row, data):
|
||||
fctr_row["closing_debit"] = fctr_row["opening_debit"] + fctr_row["debit"]
|
||||
fctr_row["closing_credit"] = fctr_row["opening_credit"] + fctr_row["credit"]
|
||||
|
||||
prepare_opening_closing(fctr_row)
|
||||
if filters.get("show_net_values"):
|
||||
prepare_opening_closing(fctr_row)
|
||||
|
||||
data.insert(idx + 1, fctr_row)
|
||||
|
||||
@@ -396,6 +371,11 @@ def update_to_presentation_currency(data, from_currency, to_currency, date, igno
|
||||
d.update(currency=to_currency)
|
||||
|
||||
|
||||
def prepare_opening_closing_for_ctb(data):
|
||||
for d in data:
|
||||
prepare_opening_closing(d)
|
||||
|
||||
|
||||
def get_columns():
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -390,7 +390,7 @@ def calculate_values(
|
||||
prepare_opening_closing(d)
|
||||
|
||||
|
||||
def calculate_total_row(accounts, company_currency):
|
||||
def calculate_total_row(data, company_currency, show_group_accounts=True):
|
||||
total_row = {
|
||||
"account": "'" + _("Total") + "'",
|
||||
"account_name": "'" + _("Total") + "'",
|
||||
@@ -407,10 +407,16 @@ def calculate_total_row(accounts, company_currency):
|
||||
"currency": company_currency,
|
||||
}
|
||||
|
||||
for d in accounts:
|
||||
if not d.parent_account:
|
||||
for field in value_fields:
|
||||
total_row[field] += d[field]
|
||||
def sum_value_fields(row):
|
||||
for field in value_fields:
|
||||
total_row[field] += row[field]
|
||||
|
||||
for d in data:
|
||||
if not show_group_accounts:
|
||||
sum_value_fields(d)
|
||||
|
||||
elif show_group_accounts and not d.get("parent_account"):
|
||||
sum_value_fields(d)
|
||||
|
||||
return total_row
|
||||
|
||||
@@ -456,11 +462,13 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
|
||||
row["has_value"] = has_value
|
||||
data.append(row)
|
||||
|
||||
total_row = calculate_total_row(accounts, company_currency)
|
||||
|
||||
if not filters.get("show_group_accounts"):
|
||||
data = hide_group_accounts(data)
|
||||
|
||||
total_row = calculate_total_row(
|
||||
data, company_currency, show_group_accounts=filters.get("show_group_accounts")
|
||||
)
|
||||
|
||||
data.extend([{}, total_row])
|
||||
|
||||
return data
|
||||
|
||||
@@ -16,6 +16,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
|
||||
|
||||
refresh() {
|
||||
this.show_general_ledger();
|
||||
erpnext.toggle_serial_batch_fields(this.frm);
|
||||
|
||||
if (this.frm.doc.stock_items && this.frm.doc.stock_items.length) {
|
||||
this.show_stock_ledger();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"doctype": "Module Onboarding",
|
||||
"idx": 0,
|
||||
"is_complete": 0,
|
||||
"modified": "2026-02-26 10:45:47.970714",
|
||||
"modified": "2026-02-26 10:50:47.970714",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Onboarding",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"is_complete": 0,
|
||||
"is_single": 0,
|
||||
"is_skipped": 0,
|
||||
"modified": "2026-02-26 10:44:59.557156",
|
||||
"modified": "2026-02-26 10:50:59.557156",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Learn Asset",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"order_confirmation_date",
|
||||
"column_break_7",
|
||||
"transaction_date",
|
||||
"transaction_time",
|
||||
"schedule_date",
|
||||
"column_break1",
|
||||
"company",
|
||||
@@ -1311,6 +1312,14 @@
|
||||
{
|
||||
"fieldname": "section_break_tnkm",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "Now",
|
||||
"depends_on": "is_internal_supplier",
|
||||
"fieldname": "transaction_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Time",
|
||||
"mandatory_depends_on": "is_internal_supplier"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -1318,7 +1327,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 14:22:33.323946",
|
||||
"modified": "2026-03-02 00:40:47.119584",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -166,6 +166,7 @@ class PurchaseOrder(BuyingController):
|
||||
total_qty: DF.Float
|
||||
total_taxes_and_charges: DF.Currency
|
||||
transaction_date: DF.Date
|
||||
transaction_time: DF.Time | None
|
||||
# end: auto-generated types
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -250,10 +250,17 @@ frappe.ui.form.on("Request for Quotation", {
|
||||
"subject",
|
||||
])
|
||||
.then((r) => {
|
||||
frm.set_value(
|
||||
"message_for_supplier",
|
||||
r.message.use_html ? r.message.response_html : r.message.response
|
||||
);
|
||||
if (r.message.use_html) {
|
||||
frm.set_value({
|
||||
mfs_html: r.message.response_html,
|
||||
use_html: 1,
|
||||
});
|
||||
} else {
|
||||
frm.set_value({
|
||||
message_for_supplier: r.message.response,
|
||||
use_html: 0,
|
||||
});
|
||||
}
|
||||
frm.set_value("subject", r.message.subject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
"send_document_print",
|
||||
"sec_break_email_2",
|
||||
"subject",
|
||||
"use_html",
|
||||
"message_for_supplier",
|
||||
"mfs_html",
|
||||
"terms_section_break",
|
||||
"incoterm",
|
||||
"named_place",
|
||||
@@ -142,12 +144,13 @@
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "Please supply the specified items at the best possible rates",
|
||||
"depends_on": "eval:doc.use_html == 0",
|
||||
"fieldname": "message_for_supplier",
|
||||
"fieldtype": "Text Editor",
|
||||
"in_list_view": 1,
|
||||
"label": "Message for Supplier",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.use_html == 0",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
@@ -324,6 +327,22 @@
|
||||
"label": "Subject",
|
||||
"not_nullable": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"depends_on": "eval:doc.use_html == 1",
|
||||
"fieldname": "mfs_html",
|
||||
"fieldtype": "Code",
|
||||
"label": "Message for Supplier",
|
||||
"mandatory_depends_on": "eval:doc.use_html == 1",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "use_html",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Use HTML"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -331,7 +350,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-06 10:31:08.747043",
|
||||
"modified": "2026-03-01 23:38:48.079274",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation",
|
||||
|
||||
@@ -47,7 +47,8 @@ class RequestforQuotation(BuyingController):
|
||||
incoterm: DF.Link | None
|
||||
items: DF.Table[RequestforQuotationItem]
|
||||
letter_head: DF.Link | None
|
||||
message_for_supplier: DF.TextEditor
|
||||
message_for_supplier: DF.TextEditor | None
|
||||
mfs_html: DF.Code | None
|
||||
named_place: DF.Data | None
|
||||
naming_series: DF.Literal["PUR-RFQ-.YYYY.-"]
|
||||
opportunity: DF.Link | None
|
||||
@@ -61,6 +62,7 @@ class RequestforQuotation(BuyingController):
|
||||
tc_name: DF.Link | None
|
||||
terms: DF.TextEditor | None
|
||||
transaction_date: DF.Date
|
||||
use_html: DF.Check
|
||||
vendor: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -100,8 +102,16 @@ class RequestforQuotation(BuyingController):
|
||||
["use_html", "response", "response_html", "subject"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not self.message_for_supplier:
|
||||
self.message_for_supplier = data.response_html if data.use_html else data.response
|
||||
|
||||
self.use_html = data.use_html
|
||||
|
||||
if data.use_html:
|
||||
if not self.mfs_html:
|
||||
self.mfs_html = data.response_html
|
||||
else:
|
||||
if not self.message_for_supplier:
|
||||
self.message_for_supplier = data.response
|
||||
|
||||
if not self.subject:
|
||||
self.subject = data.subject
|
||||
|
||||
@@ -304,7 +314,10 @@ class RequestforQuotation(BuyingController):
|
||||
else:
|
||||
sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None
|
||||
|
||||
rendered_message = frappe.render_template(self.message_for_supplier, doc_args)
|
||||
message_template = self.mfs_html if self.use_html else self.message_for_supplier
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
|
||||
rendered_message = frappe.render_template(message_template, doc_args)
|
||||
|
||||
subject_source = (
|
||||
self.subject
|
||||
or frappe.get_value("Email Template", self.email_template, "subject")
|
||||
|
||||
@@ -34,7 +34,7 @@ class TestPurchaseOrder(IntegrationTestCase):
|
||||
self.assertEqual(sq.get("items")[1].rate, 300)
|
||||
self.assertEqual(sq.get("items")[1].description, "test")
|
||||
|
||||
def test_update_supplier_quotation_child_rate_disallow(self):
|
||||
def test_update_supplier_quotation_child_rate(self):
|
||||
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
sq.submit()
|
||||
trans_item = json.dumps(
|
||||
@@ -47,6 +47,22 @@ class TestPurchaseOrder(IntegrationTestCase):
|
||||
},
|
||||
]
|
||||
)
|
||||
update_child_qty_rate("Supplier Quotation", trans_item, sq.name)
|
||||
sq.reload()
|
||||
self.assertEqual(sq.get("items")[0].rate, 300)
|
||||
po = make_purchase_order(sq.name)
|
||||
po.schedule_date = add_days(today(), 1)
|
||||
po.submit()
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": sq.items[0].item_code,
|
||||
"rate": 20,
|
||||
"qty": sq.items[0].qty,
|
||||
"docname": sq.items[0].name,
|
||||
},
|
||||
]
|
||||
)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
|
||||
)
|
||||
|
||||
@@ -165,7 +165,7 @@ def get_data(filters):
|
||||
"cost_center": po.cost_center,
|
||||
"project": po.project,
|
||||
"requesting_site": po.warehouse,
|
||||
"requestor": po.owner,
|
||||
"requestor": mr_record.get("owner", po.owner),
|
||||
"material_request_no": po.material_request,
|
||||
"item_code": po.item_code,
|
||||
"quantity": flt(po.qty),
|
||||
|
||||
@@ -2525,13 +2525,14 @@ class AccountsController(TransactionBase):
|
||||
grand_total = flt(self.get("rounded_total") or self.grand_total)
|
||||
automatically_fetch_payment_terms = 0
|
||||
|
||||
if self.doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||
base_grand_total = base_grand_total - flt(self.base_write_off_amount)
|
||||
grand_total = grand_total - flt(self.write_off_amount)
|
||||
if self.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"):
|
||||
po_or_so, doctype, fieldname = self.get_order_details()
|
||||
automatically_fetch_payment_terms = cint(
|
||||
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
|
||||
)
|
||||
if self.doctype != "Sales Order":
|
||||
base_grand_total = base_grand_total - flt(self.base_write_off_amount)
|
||||
grand_total = grand_total - flt(self.write_off_amount)
|
||||
|
||||
if self.get("total_advance"):
|
||||
if party_account_currency == self.company_currency:
|
||||
@@ -2547,7 +2548,7 @@ class AccountsController(TransactionBase):
|
||||
|
||||
if not self.get("payment_schedule"):
|
||||
if (
|
||||
self.doctype in ["Sales Invoice", "Purchase Invoice"]
|
||||
self.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"]
|
||||
and automatically_fetch_payment_terms
|
||||
and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype)
|
||||
):
|
||||
@@ -2605,16 +2606,18 @@ class AccountsController(TransactionBase):
|
||||
if not self.get("items"):
|
||||
return None, None, None
|
||||
if self.doctype == "Sales Invoice":
|
||||
po_or_so = self.get("items")[0].get("sales_order")
|
||||
po_or_so_doctype = "Sales Order"
|
||||
po_or_so_doctype_name = "sales_order"
|
||||
|
||||
prev_doc = self.get("items")[0].get("sales_order")
|
||||
prev_doctype = "Sales Order"
|
||||
prev_doctype_name = "sales_order"
|
||||
elif self.doctype == "Purchase Invoice":
|
||||
prev_doc = self.get("items")[0].get("purchase_order")
|
||||
prev_doctype = "Purchase Order"
|
||||
prev_doctype_name = "purchase_order"
|
||||
else:
|
||||
po_or_so = self.get("items")[0].get("purchase_order")
|
||||
po_or_so_doctype = "Purchase Order"
|
||||
po_or_so_doctype_name = "purchase_order"
|
||||
|
||||
return po_or_so, po_or_so_doctype, po_or_so_doctype_name
|
||||
prev_doc = self.get("items")[0].get("prevdoc_docname")
|
||||
prev_doctype = "Quotation"
|
||||
prev_doctype_name = "prevdoc_docname"
|
||||
return prev_doc, prev_doctype, prev_doctype_name
|
||||
|
||||
def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype):
|
||||
if po_or_so and self.all_items_have_same_po_or_so(po_or_so, fieldname):
|
||||
@@ -3872,20 +3875,28 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False
|
||||
return False
|
||||
|
||||
def validate_quantity(child_item, new_data):
|
||||
def validate_quantity_and_rate(child_item, new_data):
|
||||
if not flt(new_data.get("qty")) and not is_allowed_zero_qty():
|
||||
frappe.throw(
|
||||
_("Row #{0}: Quantity for Item {1} cannot be zero.").format(
|
||||
_("Row #{0}:Quantity for Item {1} cannot be zero.").format(
|
||||
new_data.get("idx"), frappe.bold(new_data.get("item_code"))
|
||||
),
|
||||
title=_("Invalid Qty"),
|
||||
)
|
||||
|
||||
if parent_doctype == "Sales Order" and flt(new_data.get("qty")) < flt(child_item.delivered_qty):
|
||||
frappe.throw(_("Cannot set quantity less than delivered quantity"))
|
||||
qty_limits = {
|
||||
"Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity")),
|
||||
"Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity")),
|
||||
}
|
||||
|
||||
if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty):
|
||||
frappe.throw(_("Cannot set quantity less than received quantity"))
|
||||
if parent_doctype in qty_limits:
|
||||
qty_field, error_message = qty_limits[parent_doctype]
|
||||
if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
|
||||
frappe.throw(
|
||||
_("Row #{0}:").format(new_data.get("idx"))
|
||||
+ error_message.format(frappe.bold(new_data.get("item_code"))),
|
||||
title=_("Invalid Qty"),
|
||||
)
|
||||
|
||||
if parent_doctype in ["Quotation", "Supplier Quotation"]:
|
||||
if (parent_doctype == "Quotation" and not ordered_items) or (
|
||||
@@ -3898,7 +3909,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
if parent_doctype == "Quotation"
|
||||
else purchased_items.get(child_item.name)
|
||||
)
|
||||
|
||||
if qty_to_check:
|
||||
if not rate_unchanged:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot update rate as item {0} is already ordered or purchased against this quotation"
|
||||
).format(frappe.bold(new_data.get("item_code")))
|
||||
)
|
||||
|
||||
if flt(new_data.get("qty")) < qty_to_check:
|
||||
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
|
||||
|
||||
@@ -4017,10 +4036,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
):
|
||||
continue
|
||||
|
||||
validate_quantity(child_item, d)
|
||||
if parent_doctype in ["Quotation", "Supplier Quotation"]:
|
||||
if not rate_unchanged:
|
||||
frappe.throw(_("Rates cannot be modified for quoted items"))
|
||||
validate_quantity_and_rate(child_item, d)
|
||||
|
||||
if flt(child_item.get("qty")) != flt(d.get("qty")):
|
||||
any_qty_changed = True
|
||||
|
||||
@@ -1012,7 +1012,14 @@ def get_serial_batches_based_on_bundle(doctype, field, _bundle_ids):
|
||||
|
||||
if doctype == "Packed Item":
|
||||
if key is None:
|
||||
key = frappe.get_cached_value("Packed Item", row.voucher_detail_no, field)
|
||||
key = frappe.get_cached_value(
|
||||
"Packed Item",
|
||||
{"parent_detail_docname": row.voucher_detail_no, "item_code": row.item_code},
|
||||
field,
|
||||
)
|
||||
if key is None:
|
||||
key = frappe.get_cached_value("Packed Item", row.voucher_detail_no, field)
|
||||
|
||||
if row.voucher_type == "Delivery Note":
|
||||
key = frappe.get_cached_value("Delivery Note Item", key, "dn_detail")
|
||||
elif row.voucher_type == "Sales Invoice":
|
||||
|
||||
@@ -333,9 +333,10 @@ class SellingController(StockController):
|
||||
if is_internal_customer or not is_stock_item:
|
||||
continue
|
||||
|
||||
if item.get("incoming_rate") and item.base_net_rate < (
|
||||
rate_field = "valuation_rate" if self.doctype in ["Sales Order", "Quotation"] else "incoming_rate"
|
||||
if item.get(rate_field) and item.base_net_rate < (
|
||||
valuation_rate := flt(
|
||||
item.incoming_rate * (item.conversion_factor or 1), item.precision("base_net_rate")
|
||||
item.get(rate_field) * (item.conversion_factor or 1), item.precision("base_net_rate")
|
||||
)
|
||||
):
|
||||
throw_message(
|
||||
|
||||
@@ -63,6 +63,8 @@ class StockController(AccountsController):
|
||||
|
||||
if not self.get("is_return"):
|
||||
self.validate_inspection()
|
||||
|
||||
self.validate_warehouse_of_sabb()
|
||||
self.validate_serialized_batch()
|
||||
self.clean_serial_nos()
|
||||
self.validate_customer_provided_item()
|
||||
@@ -75,6 +77,45 @@ class StockController(AccountsController):
|
||||
super().on_update()
|
||||
self.check_zero_rate()
|
||||
|
||||
def validate_warehouse_of_sabb(self):
|
||||
if self.is_internal_transfer():
|
||||
return
|
||||
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
|
||||
for row in self.items:
|
||||
if not row.get("serial_and_batch_bundle"):
|
||||
continue
|
||||
|
||||
sabb_details = frappe.db.get_value(
|
||||
"Serial and Batch Bundle",
|
||||
row.serial_and_batch_bundle,
|
||||
["type_of_transaction", "warehouse", "has_serial_no"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not sabb_details:
|
||||
continue
|
||||
|
||||
if sabb_details.type_of_transaction != "Outward":
|
||||
continue
|
||||
|
||||
warehouse = row.get("warehouse") or row.get("s_warehouse")
|
||||
if sabb_details.warehouse != warehouse:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Warehouse {1} does not match with the warehouse {2} in Serial and Batch Bundle {3}."
|
||||
).format(row.idx, warehouse, sabb_details.warehouse, row.serial_and_batch_bundle)
|
||||
)
|
||||
|
||||
if self.doctype == "Stock Reconciliation":
|
||||
continue
|
||||
|
||||
if sabb_details.has_serial_no and doc_before_save and doc_before_save.get("items"):
|
||||
prev_row = doc_before_save.get("items", {"idx": row.idx})
|
||||
if prev_row and prev_row[0].serial_and_batch_bundle != row.serial_and_batch_bundle:
|
||||
sabb_doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
|
||||
sabb_doc.validate_serial_no_status()
|
||||
|
||||
def reset_conversion_factor(self):
|
||||
for row in self.get("items"):
|
||||
if row.uom != row.stock_uom:
|
||||
@@ -2087,7 +2128,9 @@ def check_item_quality_inspection(doctype, items):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_quality_inspections(doctype, docname, items, inspection_type):
|
||||
def make_quality_inspections(
|
||||
company: str, doctype: str, docname: str, items: str | list, inspection_type: str
|
||||
):
|
||||
if isinstance(items, str):
|
||||
items = json.loads(items)
|
||||
|
||||
@@ -2106,6 +2149,7 @@ def make_quality_inspections(doctype, docname, items, inspection_type):
|
||||
|
||||
quality_inspection = frappe.get_doc(
|
||||
{
|
||||
"company": company,
|
||||
"doctype": "Quality Inspection",
|
||||
"inspection_type": inspection_type,
|
||||
"inspected_by": frappe.session.user,
|
||||
|
||||
@@ -307,6 +307,21 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
|
||||
};
|
||||
});
|
||||
|
||||
this.frm.set_query("uom", "items", function (doc, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
|
||||
if (!row.item_code) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
query: "erpnext.controllers.queries.get_item_uom_query",
|
||||
filters: {
|
||||
item_code: row.item_code,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
me.frm.set_query("contact_person", erpnext.queries["contact_query"]);
|
||||
|
||||
if (me.frm.doc.opportunity_from == "Lead") {
|
||||
|
||||
@@ -59,7 +59,9 @@ def create_prospect_against_crm_deal():
|
||||
)
|
||||
pass
|
||||
|
||||
create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name)
|
||||
if doc.contacts and len(doc.contacts):
|
||||
create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name)
|
||||
|
||||
create_address("Prospect", prospect_name, doc.address)
|
||||
frappe.response["message"] = prospect_name
|
||||
|
||||
|
||||
21
erpnext/desktop_icon/organization.json
Normal file
21
erpnext/desktop_icon/organization.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"bg_color": "blue",
|
||||
"creation": "2026-02-24 17:43:08.379896",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon_type": "Link",
|
||||
"idx": 0,
|
||||
"label": "Organization",
|
||||
"link_to": "Organization",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-02-24 17:59:39.885360",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Organization",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
}
|
||||
@@ -219,6 +219,16 @@ website_route_rules = [
|
||||
{"from_route": "/tasks", "to_route": "Task"},
|
||||
]
|
||||
|
||||
standard_navbar_items = [
|
||||
{
|
||||
"item_label": "Clear Demo Data",
|
||||
"item_type": "Action",
|
||||
"action": "erpnext.demo.clear_demo();",
|
||||
"is_standard": 1,
|
||||
"condition": "eval: frappe.boot.sysdefaults.demo_company",
|
||||
},
|
||||
]
|
||||
|
||||
standard_portal_menu_items = [
|
||||
{"title": "Projects", "route": "/project", "reference_doctype": "Project", "role": "Customer"},
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1321,9 +1321,9 @@ class JobCard(Document):
|
||||
|
||||
def is_work_order_closed(self):
|
||||
if self.work_order:
|
||||
status = frappe.get_value("Work Order", self.work_order)
|
||||
status = frappe.get_value("Work Order", self.work_order, "status")
|
||||
|
||||
if status == "Closed":
|
||||
if status in ["Closed", "Stopped"]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -225,7 +225,12 @@ class WorkOrder(Document):
|
||||
frappe.throw(_("Actual End Date cannot be before Actual Start Date"))
|
||||
|
||||
def validate_fg_warehouse_for_reservation(self):
|
||||
if self.reserve_stock and self.sales_order and not self.subcontracting_inward_order:
|
||||
if (
|
||||
self.reserve_stock
|
||||
and self.sales_order
|
||||
and not self.subcontracting_inward_order
|
||||
and not self.production_plan_sub_assembly_item
|
||||
):
|
||||
warehouses = frappe.get_all(
|
||||
"Sales Order Item",
|
||||
filters={"parent": self.sales_order, "item_code": self.production_item},
|
||||
@@ -413,39 +418,52 @@ class WorkOrder(Document):
|
||||
)
|
||||
|
||||
def validate_sales_order(self):
|
||||
if self.production_plan_sub_assembly_item:
|
||||
return
|
||||
|
||||
if self.sales_order:
|
||||
self.check_sales_order_on_hold_or_close()
|
||||
so = frappe.db.sql(
|
||||
"""
|
||||
select so.name, so_item.delivery_date, so.project
|
||||
from `tabSales Order` so
|
||||
inner join `tabSales Order Item` so_item on so_item.parent = so.name
|
||||
left join `tabProduct Bundle Item` pk_item on so_item.item_code = pk_item.parent
|
||||
where so.name=%s and so.docstatus = 1
|
||||
and so.skip_delivery_note = 0 and (
|
||||
so_item.item_code=%s or
|
||||
pk_item.item_code=%s )
|
||||
""",
|
||||
(self.sales_order, self.production_item, self.production_item),
|
||||
as_dict=1,
|
||||
|
||||
SalesOrder = frappe.qb.DocType("Sales Order")
|
||||
SalesOrderItem = frappe.qb.DocType("Sales Order Item")
|
||||
PackedItem = frappe.qb.DocType("Packed Item")
|
||||
ProductBundleItem = frappe.qb.DocType("Product Bundle Item")
|
||||
|
||||
so = (
|
||||
frappe.qb.from_(SalesOrder)
|
||||
.inner_join(SalesOrderItem)
|
||||
.on(SalesOrderItem.parent == SalesOrder.name)
|
||||
.left_join(ProductBundleItem)
|
||||
.on(ProductBundleItem.parent == SalesOrderItem.item_code)
|
||||
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
|
||||
.where(
|
||||
(SalesOrder.skip_delivery_note == 0)
|
||||
& (SalesOrder.docstatus == 1)
|
||||
& (SalesOrder.name == self.sales_order)
|
||||
& (
|
||||
(SalesOrderItem.item_code == self.production_item)
|
||||
| (ProductBundleItem.item_code == self.production_item)
|
||||
)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if not so:
|
||||
so = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
so.name, so_item.delivery_date, so.project
|
||||
from
|
||||
`tabSales Order` so, `tabSales Order Item` so_item, `tabPacked Item` packed_item
|
||||
where so.name=%s
|
||||
and so.name=so_item.parent
|
||||
and so.name=packed_item.parent
|
||||
and so.skip_delivery_note = 0
|
||||
and so_item.item_code = packed_item.parent_item
|
||||
and so.docstatus = 1 and packed_item.item_code=%s
|
||||
""",
|
||||
(self.sales_order, self.production_item),
|
||||
as_dict=1,
|
||||
so = (
|
||||
frappe.qb.from_(SalesOrder)
|
||||
.inner_join(SalesOrderItem)
|
||||
.on(SalesOrderItem.parent == SalesOrder.name)
|
||||
.inner_join(PackedItem)
|
||||
.on(PackedItem.parent == SalesOrder.name)
|
||||
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
|
||||
.where(
|
||||
(SalesOrder.name == self.sales_order)
|
||||
& (SalesOrder.skip_delivery_note == 0)
|
||||
& (SalesOrderItem.item_code == PackedItem.parent_item)
|
||||
& (SalesOrder.docstatus == 1)
|
||||
& (PackedItem.item_code == self.production_item)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if len(so):
|
||||
@@ -651,7 +669,7 @@ class WorkOrder(Document):
|
||||
|
||||
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
|
||||
|
||||
if self.sales_order and self.sales_order_item:
|
||||
if self.sales_order and self.sales_order_item and not self.production_plan_sub_assembly_item:
|
||||
update_produced_qty_in_so_item(self.sales_order, self.sales_order_item)
|
||||
|
||||
if self.production_plan:
|
||||
@@ -695,19 +713,25 @@ class WorkOrder(Document):
|
||||
self.db_set("disassembled_qty", self.disassembled_qty)
|
||||
|
||||
def get_transferred_or_manufactured_qty(self, purpose, fieldname):
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
query = frappe.qb.from_(table).where(
|
||||
(table.work_order == self.name) & (table.docstatus == 1) & (table.purpose == purpose)
|
||||
parent = frappe.qb.DocType("Stock Entry")
|
||||
|
||||
query = frappe.qb.from_(parent).where(
|
||||
(parent.work_order == self.name)
|
||||
& (parent.docstatus == 1)
|
||||
& (parent.purpose == purpose)
|
||||
& (parent.is_additional_transfer_entry == cint(fieldname == "additional_transferred_qty"))
|
||||
)
|
||||
|
||||
if purpose == "Manufacture":
|
||||
query = query.select(Sum(table.fg_completed_qty) - Sum(table.process_loss_qty))
|
||||
child = frappe.qb.DocType("Stock Entry Detail")
|
||||
query = (
|
||||
query.join(child)
|
||||
.on(parent.name == child.parent)
|
||||
.select(Sum(child.transfer_qty))
|
||||
.where(child.is_finished_item == 1)
|
||||
)
|
||||
else:
|
||||
query = query.select(Sum(table.fg_completed_qty))
|
||||
|
||||
query = query.where(
|
||||
table.is_additional_transfer_entry == cint(fieldname == "additional_transferred_qty")
|
||||
)
|
||||
query = query.select(Sum(parent.fg_completed_qty))
|
||||
|
||||
return flt(query.run()[0][0])
|
||||
|
||||
@@ -1159,7 +1183,7 @@ class WorkOrder(Document):
|
||||
doc.db_set("status", doc.status)
|
||||
|
||||
def update_work_order_qty_in_so(self):
|
||||
if not self.sales_order and not self.sales_order_item:
|
||||
if (not self.sales_order and not self.sales_order_item) or self.production_plan_sub_assembly_item:
|
||||
return
|
||||
|
||||
total_bundle_qty = 1
|
||||
|
||||
@@ -469,3 +469,5 @@ erpnext.patches.v15_0.delete_quotation_lost_record_detail
|
||||
erpnext.patches.v16_0.add_portal_redirects
|
||||
erpnext.patches.v16_0.complete_onboarding_steps_for_older_sites #2
|
||||
erpnext.patches.v16_0.migrate_asset_type_checkboxes_to_select
|
||||
erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
|
||||
erpnext.patches.v16_0.enable_serial_batch_setting
|
||||
|
||||
9
erpnext/patches/v16_0/enable_serial_batch_setting.py
Normal file
9
erpnext/patches/v16_0/enable_serial_batch_setting.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
if not frappe.get_all("Serial No", limit=1) and not frappe.get_all("Batch", limit=1):
|
||||
return
|
||||
|
||||
frappe.db.set_single_value("Stock Settings", "enable_serial_and_batch_no_for_item", 1)
|
||||
frappe.db.set_default("enable_serial_and_batch_no_for_item", 1)
|
||||
@@ -0,0 +1,33 @@
|
||||
import frappe
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
|
||||
def execute():
|
||||
PurchaseOrderItem = DocType("Purchase Order Item")
|
||||
MaterialRequestItem = DocType("Material Request Item")
|
||||
|
||||
poi_query = (
|
||||
frappe.qb.from_(PurchaseOrderItem)
|
||||
.select(PurchaseOrderItem.sales_order_item, Sum(PurchaseOrderItem.stock_qty))
|
||||
.where(PurchaseOrderItem.sales_order_item.isnotnull() & PurchaseOrderItem.docstatus == 1)
|
||||
.groupby(PurchaseOrderItem.sales_order_item)
|
||||
)
|
||||
|
||||
mri_query = (
|
||||
frappe.qb.from_(MaterialRequestItem)
|
||||
.select(MaterialRequestItem.sales_order_item, Sum(MaterialRequestItem.stock_qty))
|
||||
.where(MaterialRequestItem.sales_order_item.isnotnull() & MaterialRequestItem.docstatus == 1)
|
||||
.groupby(MaterialRequestItem.sales_order_item)
|
||||
)
|
||||
|
||||
poi_data = poi_query.run()
|
||||
mri_data = mri_query.run()
|
||||
|
||||
updates_against_poi = {data[0]: {"ordered_qty": data[1]} for data in poi_data}
|
||||
updates_against_mri = {data[0]: {"requested_qty": data[1], "ordered_qty": 0} for data in mri_data}
|
||||
|
||||
frappe.db.auto_commit_on_many_writes = 1
|
||||
frappe.db.bulk_update("Sales Order Item", updates_against_mri)
|
||||
frappe.db.bulk_update("Sales Order Item", updates_against_poi)
|
||||
frappe.db.auto_commit_on_many_writes = 0
|
||||
@@ -205,7 +205,7 @@ frappe.ui.form.on("Project", {
|
||||
|
||||
collect_progress: function (frm) {
|
||||
if (frm.doc.collect_progress && !frm.doc.subject) {
|
||||
frm.set_value("subject", __("For project {0}, update your status", [frm.doc.name]));
|
||||
frm.set_value("subject", __("For project - {0}, update your status", [frm.doc.project_name]));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,29 +12,21 @@
|
||||
"project_name",
|
||||
"status",
|
||||
"project_type",
|
||||
"is_active",
|
||||
"percent_complete_method",
|
||||
"percent_complete",
|
||||
"column_break_5",
|
||||
"project_template",
|
||||
"expected_start_date",
|
||||
"expected_end_date",
|
||||
"priority",
|
||||
"department",
|
||||
"customer_details",
|
||||
"customer",
|
||||
"column_break_14",
|
||||
"sales_order",
|
||||
"users_section",
|
||||
"users",
|
||||
"copied_from",
|
||||
"section_break0",
|
||||
"notes",
|
||||
"is_active",
|
||||
"percent_complete",
|
||||
"section_break_18",
|
||||
"expected_start_date",
|
||||
"actual_start_date",
|
||||
"actual_time",
|
||||
"column_break_20",
|
||||
"expected_end_date",
|
||||
"actual_end_date",
|
||||
"costing_tab",
|
||||
"project_details",
|
||||
"estimated_costing",
|
||||
"total_costing_amount",
|
||||
@@ -50,7 +42,7 @@
|
||||
"gross_margin",
|
||||
"column_break_37",
|
||||
"per_gross_margin",
|
||||
"monitor_progress",
|
||||
"monitor_progress_tab",
|
||||
"collect_progress",
|
||||
"holiday_list",
|
||||
"frequency",
|
||||
@@ -63,7 +55,18 @@
|
||||
"weekly_time_to_send",
|
||||
"column_break_45",
|
||||
"subject",
|
||||
"message"
|
||||
"message",
|
||||
"more_info_tab",
|
||||
"customer_details",
|
||||
"customer",
|
||||
"column_break_14",
|
||||
"sales_order",
|
||||
"users_section",
|
||||
"users",
|
||||
"copied_from",
|
||||
"section_break0",
|
||||
"notes",
|
||||
"connections_tab"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -231,7 +234,7 @@
|
||||
"collapsible": 1,
|
||||
"fieldname": "section_break_18",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Start and End Dates"
|
||||
"label": "Timeline"
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_start_date",
|
||||
@@ -258,7 +261,6 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "project_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Costing and Billing",
|
||||
@@ -329,7 +331,6 @@
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "margin",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Margin",
|
||||
@@ -357,12 +358,6 @@
|
||||
"oldfieldtype": "Currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "monitor_progress",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Monitor Progress"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "collect_progress",
|
||||
@@ -455,6 +450,27 @@
|
||||
"fieldtype": "Data",
|
||||
"label": "Subject",
|
||||
"mandatory_depends_on": "collect_progress"
|
||||
},
|
||||
{
|
||||
"fieldname": "costing_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Costing"
|
||||
},
|
||||
{
|
||||
"fieldname": "monitor_progress_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Progress"
|
||||
},
|
||||
{
|
||||
"fieldname": "more_info_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "More Info"
|
||||
},
|
||||
{
|
||||
"fieldname": "connections_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Connections",
|
||||
"show_dashboard": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-puzzle-piece",
|
||||
@@ -462,7 +478,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"max_attachments": 4,
|
||||
"modified": "2025-08-21 17:57:58.314809",
|
||||
"modified": "2026-03-04 11:09:55.253367",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Project",
|
||||
|
||||
@@ -19,6 +19,13 @@ frappe.ui.form.on("Project Template", {
|
||||
frappe.ui.form.on("Project Template Task", {
|
||||
task: function (frm, cdt, cdn) {
|
||||
var row = locals[cdt][cdn];
|
||||
|
||||
if (!row.task) {
|
||||
row.subject = null;
|
||||
refresh_field("tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.db.get_value("Task", row.task, "subject", (value) => {
|
||||
row.subject = value.subject;
|
||||
refresh_field("tasks");
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"type",
|
||||
"color",
|
||||
"is_group",
|
||||
"is_template",
|
||||
"column_break0",
|
||||
"status",
|
||||
"priority",
|
||||
@@ -21,17 +20,21 @@
|
||||
"parent_task",
|
||||
"completed_by",
|
||||
"completed_on",
|
||||
"section_break_dafi",
|
||||
"is_template",
|
||||
"column_break_vvfp",
|
||||
"start",
|
||||
"duration",
|
||||
"sb_timeline",
|
||||
"exp_start_date",
|
||||
"expected_time",
|
||||
"start",
|
||||
"column_break_11",
|
||||
"exp_end_date",
|
||||
"progress",
|
||||
"duration",
|
||||
"is_milestone",
|
||||
"sb_details",
|
||||
"description",
|
||||
"dependencies_tab",
|
||||
"sb_depends_on",
|
||||
"depends_on",
|
||||
"depends_on_tasks",
|
||||
@@ -44,12 +47,13 @@
|
||||
"total_costing_amount",
|
||||
"column_break_20",
|
||||
"total_billing_amount",
|
||||
"more_info_tab",
|
||||
"sb_more_info",
|
||||
"company",
|
||||
"review_date",
|
||||
"closing_date",
|
||||
"column_break_22",
|
||||
"department",
|
||||
"company",
|
||||
"lft",
|
||||
"rgt",
|
||||
"old_parent",
|
||||
@@ -78,7 +82,6 @@
|
||||
"oldfieldname": "project",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Project",
|
||||
"remember_last_selected_value": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
@@ -218,7 +221,6 @@
|
||||
{
|
||||
"fieldname": "sb_depends_on",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Dependencies",
|
||||
"oldfieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
@@ -298,10 +300,9 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "sb_more_info",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "More Info"
|
||||
"label": "Additional Info"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.status == \"Closed\" || doc.status == \"Pending Review\"",
|
||||
@@ -334,8 +335,7 @@
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company",
|
||||
"options": "Company",
|
||||
"remember_last_selected_value": 1
|
||||
"options": "Company"
|
||||
},
|
||||
{
|
||||
"fieldname": "lft",
|
||||
@@ -368,6 +368,7 @@
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"default": "0",
|
||||
"fieldname": "is_template",
|
||||
"fieldtype": "Check",
|
||||
@@ -397,6 +398,24 @@
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Template Task"
|
||||
},
|
||||
{
|
||||
"fieldname": "dependencies_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Dependencies"
|
||||
},
|
||||
{
|
||||
"fieldname": "more_info_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "More Info"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_dafi",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_vvfp",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-check",
|
||||
@@ -404,11 +423,11 @@
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"max_attachments": 5,
|
||||
"modified": "2025-10-16 08:39:12.214577",
|
||||
"modified": "2026-03-04 11:47:10.454548",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Task",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"nsm_parent_field": "parent_task",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
@@ -425,6 +444,7 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "subject",
|
||||
"show_name_in_global_search": 1,
|
||||
"show_preview_popup": 1,
|
||||
@@ -434,4 +454,4 @@
|
||||
"timeline_field": "project",
|
||||
"title_field": "subject",
|
||||
"track_seen": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,8 @@ class Task(NestedSet):
|
||||
def validate_status(self):
|
||||
if self.is_template and self.status != "Template":
|
||||
self.status = "Template"
|
||||
if self.status == "Template" and not self.is_template:
|
||||
self.status = "Open"
|
||||
if self.status != self.get_db_value("status") and self.status == "Completed":
|
||||
for d in self.depends_on:
|
||||
if frappe.db.get_value("Task", d.task, "status") not in ("Completed", "Cancelled"):
|
||||
|
||||
@@ -260,6 +260,33 @@ frappe.ui.form.on("Timesheet", {
|
||||
parent_project: function (frm) {
|
||||
set_project_in_timelog(frm);
|
||||
},
|
||||
|
||||
employee: function (frm) {
|
||||
if (frm.doc.employee && frm.doc.time_logs) {
|
||||
const selected_employee = frm.doc.employee;
|
||||
frm.doc.time_logs.forEach((row) => {
|
||||
if (row.activity_type) {
|
||||
frappe.call({
|
||||
method: "erpnext.projects.doctype.timesheet.timesheet.get_activity_cost",
|
||||
args: {
|
||||
employee: frm.doc.employee,
|
||||
activity_type: row.activity_type,
|
||||
currency: frm.doc.currency,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
if (selected_employee !== frm.doc.employee) return;
|
||||
row.billing_rate = r.message["billing_rate"];
|
||||
row.costing_rate = r.message["costing_rate"];
|
||||
frm.refresh_fields("time_logs");
|
||||
calculate_billing_costing_amount(frm, row.doctype, row.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Timesheet Detail", {
|
||||
|
||||
@@ -18,28 +18,29 @@
|
||||
"column_break_3",
|
||||
"status",
|
||||
"parent_project",
|
||||
"employee_detail",
|
||||
"employee",
|
||||
"employee_name",
|
||||
"department",
|
||||
"column_break_9",
|
||||
"user",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"employee_detail",
|
||||
"employee",
|
||||
"department",
|
||||
"column_break_9",
|
||||
"employee_name",
|
||||
"section_break_5",
|
||||
"time_logs",
|
||||
"working_hours",
|
||||
"total_hours",
|
||||
"billing_tab",
|
||||
"billing_details",
|
||||
"total_billable_hours",
|
||||
"total_billable_amount",
|
||||
"total_costing_amount",
|
||||
"base_total_billable_amount",
|
||||
"base_total_billed_amount",
|
||||
"base_total_costing_amount",
|
||||
"column_break_10",
|
||||
"total_billed_hours",
|
||||
"total_billable_amount",
|
||||
"total_billed_amount",
|
||||
"total_costing_amount",
|
||||
"base_total_billed_amount",
|
||||
"per_billed",
|
||||
"section_break_18",
|
||||
"note",
|
||||
@@ -176,7 +177,6 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "billing_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing Details",
|
||||
@@ -304,13 +304,18 @@
|
||||
"fieldname": "exchange_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Exchange Rate"
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Billing"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-clock-o",
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-19 13:48:23.453636",
|
||||
"modified": "2026-03-04 11:56:51.438298",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Timesheet",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
|
||||
<path d="M19.2857 15.4286H22.1786C23.7763 15.4286 25.0714 16.7237 25.0714 18.3214V24.1071C25.0714 25.7048 23.7763 27 22.1786 27H19.2857V38.5714H15.4286V27H11.5714V23.1428H21.2143V19.2857H11.5714V15.4286H15.4286V11.5714H19.2857V15.4286ZM38.5714 38.5714H34.7143V34.7143H31.8214C30.2238 34.7143 28.9286 33.4191 28.9286 31.8214V26.0357C28.9286 24.438 30.2238 23.1428 31.8214 23.1428H34.7143V11.5714H38.5714V23.1428H42.4286V27H32.7857V30.8571H42.4286V34.7143H38.5714V38.5714Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 787 B |
@@ -0,0 +1,5 @@
|
||||
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 0H8C3.58172 0 0 3.58172 0 8V20C0 24.4183 3.58172 28 8 28H20C24.4183 28 28 24.4183 28 20V8C28 3.58172 24.4183 0 20 0Z" fill="#0289F7"/>
|
||||
<path d="M20.5 13.25C20.5 13.0926 20.5 13 20.5 13L18.5 11.499V19.5H20C20.2761 19.5 20 19.5 20.5 19.5V13.25ZM14.5 14V16H10.5V14H14.5ZM22.5 19C22.5 20.3807 21.3807 21.5 20 21.5H16.5V19.5V7.5C16.5 7.5 16.2761 7.5 16 7.5H9C8.72386 7.5 9 7.5 8.5 7.5V19.5C9 19.5 8.72386 19.5 9 19.5H12.5V21.5H9C7.61929 21.5 6.5 20.3807 6.5 19V8C6.5 6.61929 7.61929 5.5 9 5.5H16C17.3807 5.5 18.5 6.61929 18.5 8V9L21.5 11.25C22.1295 11.7221 22.5 12.4631 22.5 13.25V19Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 713 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
|
||||
<path d="M19.2857 15.4286H22.1786C23.7762 15.4286 25.0714 16.7238 25.0714 18.3215V24.1072C25.0714 25.7049 23.7762 27 22.1786 27H19.2857V38.5715H15.4286V27H11.5714V23.1429H21.2143V19.2858H11.5714V15.4286H15.4286V11.5715H19.2857V15.4286ZM38.5714 38.5715H34.7143V34.7143H31.8214C30.2237 34.7143 28.9286 33.4192 28.9286 31.8215V26.0358C28.9286 24.4381 30.2237 23.1429 31.8214 23.1429H34.7143V11.5715H38.5714V23.1429H42.4286V27H32.7857V30.8572H42.4286V34.7143H38.5714V38.5715Z" fill="#0981E3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 809 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 0H8C3.58172 0 0 3.58172 0 8V20C0 24.4183 3.58172 28 8 28H20C24.4183 28 28 24.4183 28 20V8C28 3.58172 24.4183 0 20 0Z" fill="#0289F7" fill-opacity="0.1"/>
|
||||
<path d="M20.5 13.25C20.5 13.0926 20.5 13 20.5 13L18.5 11.499V19.5H20C20.2761 19.5 20 19.5 20.5 19.5V13.25ZM14.5 14V16H10.5V14H14.5ZM22.5 19C22.5 20.3807 21.3807 21.5 20 21.5H16.5V19.5V7.5C16.5 7.5 16.2761 7.5 16 7.5H9C8.72386 7.5 9 7.5 8.5 7.5V19.5C9 19.5 8.72386 19.5 9 19.5H12.5V21.5H9C7.61929 21.5 6.5 20.3807 6.5 19V8C6.5 6.61929 7.61929 5.5 9 5.5H16C17.3807 5.5 18.5 6.61929 18.5 8V9L21.5 11.25C22.1295 11.7221 22.5 12.4631 22.5 13.25V19Z" fill="#0981E3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 733 B |
@@ -580,6 +580,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
this.validate_has_items();
|
||||
erpnext.utils.view_serial_batch_nos(this.frm);
|
||||
this.set_route_options_for_new_doc();
|
||||
erpnext.toggle_serial_batch_fields(this.frm);
|
||||
}
|
||||
|
||||
set_route_options_for_new_doc() {
|
||||
@@ -1307,6 +1308,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
if (this.frm.doc.transaction_date) {
|
||||
this.frm.transaction_date = this.frm.doc.transaction_date;
|
||||
frappe.ui.form.trigger(this.frm.doc.doctype, "currency");
|
||||
this.recalculate_terms();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2966,6 +2968,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
frappe.call({
|
||||
method: "erpnext.controllers.stock_controller.make_quality_inspections",
|
||||
args: {
|
||||
company: me.frm.doc.company,
|
||||
doctype: me.frm.doc.doctype,
|
||||
docname: me.frm.doc.name,
|
||||
items: selected_data,
|
||||
|
||||
@@ -19,6 +19,71 @@ $.extend(erpnext, {
|
||||
return currency_list;
|
||||
},
|
||||
|
||||
toggle_serial_batch_fields(frm) {
|
||||
let hide_fields = cint(frappe.user_defaults?.enable_serial_and_batch_no_for_item) === 0 ? 1 : 0;
|
||||
let fields = ["serial_and_batch_bundle", "use_serial_batch_fields", "serial_no", "batch_no"];
|
||||
|
||||
if (
|
||||
[
|
||||
"Stock Entry",
|
||||
"Purchase Receipt",
|
||||
"Purchase Invoice",
|
||||
"Stock Reconciliation",
|
||||
"Subcontracting Receipt",
|
||||
].includes(frm.doc.doctype)
|
||||
) {
|
||||
fields.push("add_serial_batch_bundle");
|
||||
}
|
||||
|
||||
if (["Stock Reconciliation"].includes(frm.doc.doctype)) {
|
||||
fields.push("reconcile_all_serial_batch");
|
||||
}
|
||||
|
||||
if (["Sales Invoice", "Delivery Note", "Pick List"].includes(frm.doc.doctype)) {
|
||||
fields.push("pick_serial_and_batch");
|
||||
}
|
||||
|
||||
if (["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(frm.doc.doctype)) {
|
||||
fields.push("add_serial_batch_for_rejected_qty", "rejected_serial_and_batch_bundle");
|
||||
}
|
||||
|
||||
let child_name = "items";
|
||||
if (frm.doc.doctype === "Pick List") {
|
||||
child_name = "locations";
|
||||
}
|
||||
|
||||
if (frm.doc.doctype === "Asset Capitalization") {
|
||||
child_name = "stock_items";
|
||||
}
|
||||
|
||||
fields.forEach((field) => {
|
||||
frm.fields_dict[child_name].grid.update_docfield_property(field, "hidden", hide_fields);
|
||||
|
||||
frm.fields_dict[child_name].grid.update_docfield_property(
|
||||
field,
|
||||
"in_list_view",
|
||||
hide_fields ? 0 : 1
|
||||
);
|
||||
|
||||
if (
|
||||
frm.doc.doctype === "Subcontracting Receipt" &&
|
||||
!["add_serial_batch_for_rejected_qty", "rejected_serial_and_batch_bundle"].includes(field)
|
||||
) {
|
||||
frm.fields_dict["supplied_items"].grid.update_docfield_property(field, "hidden", hide_fields);
|
||||
|
||||
frm.fields_dict["supplied_items"].grid.update_docfield_property(
|
||||
field,
|
||||
"in_list_view",
|
||||
hide_fields ? 0 : 1
|
||||
);
|
||||
|
||||
frm.fields_dict["supplied_items"].grid.reset_grid();
|
||||
}
|
||||
});
|
||||
|
||||
frm.fields_dict[child_name].grid.reset_grid();
|
||||
},
|
||||
|
||||
toggle_naming_series: function () {
|
||||
if (
|
||||
cur_frm &&
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import flt, getdate, nowdate
|
||||
from frappe.utils import cint, flt, getdate, nowdate
|
||||
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
|
||||
@@ -446,6 +446,10 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
|
||||
child_filter = d.name in filtered_items if filtered_items else True
|
||||
return child_filter
|
||||
|
||||
automatically_fetch_payment_terms = cint(
|
||||
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
|
||||
)
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Quotation",
|
||||
source_name,
|
||||
@@ -453,6 +457,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
|
||||
"Quotation": {
|
||||
"doctype": "Sales Order",
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
"field_no_map": ["payment_terms_template"],
|
||||
},
|
||||
"Quotation Item": {
|
||||
"doctype": "Sales Order Item",
|
||||
@@ -462,13 +467,15 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
|
||||
},
|
||||
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
|
||||
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
|
||||
"Payment Schedule": {"doctype": "Payment Schedule", "add_if_empty": True},
|
||||
},
|
||||
target_doc,
|
||||
set_missing_values,
|
||||
ignore_permissions=ignore_permissions,
|
||||
)
|
||||
|
||||
if automatically_fetch_payment_terms:
|
||||
doclist.set_payment_schedule()
|
||||
|
||||
return doclist
|
||||
|
||||
|
||||
|
||||
@@ -59,8 +59,22 @@ class TestQuotation(IntegrationTestCase):
|
||||
qo.payment_schedule[0].due_date = add_days(qo.transaction_date, -2)
|
||||
self.assertRaises(frappe.ValidationError, qo.save)
|
||||
|
||||
def test_update_child_disallow_rate_change(self):
|
||||
qo = make_quotation(qty=4)
|
||||
def test_update_child_rate_change(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_1 = make_item("_Test Item")
|
||||
item_2 = make_item("_Test Item 1")
|
||||
|
||||
item_list = [
|
||||
{"item_code": item_1.item_code, "warehouse": "_Test Warehouse - _TC", "qty": 10, "rate": 300},
|
||||
{"item_code": item_2.item_code, "warehouse": "_Test Warehouse - _TC", "qty": 5, "rate": 400},
|
||||
]
|
||||
|
||||
qo = make_quotation(item_list=item_list)
|
||||
so = make_sales_order(qo.name, args={"filtered_children": [qo.items[0].name]})
|
||||
so.delivery_date = nowdate()
|
||||
so.submit()
|
||||
qo.reload()
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{
|
||||
@@ -68,10 +82,35 @@ class TestQuotation(IntegrationTestCase):
|
||||
"rate": 5000,
|
||||
"qty": qo.items[0].qty,
|
||||
"docname": qo.items[0].name,
|
||||
}
|
||||
},
|
||||
{
|
||||
"item_code": qo.items[1].item_code,
|
||||
"rate": qo.items[1].rate,
|
||||
"qty": qo.items[1].qty,
|
||||
"docname": qo.items[1].name,
|
||||
},
|
||||
]
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name)
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": qo.items[0].item_code,
|
||||
"rate": qo.items[0].rate,
|
||||
"qty": qo.items[0].qty,
|
||||
"docname": qo.items[0].name,
|
||||
},
|
||||
{
|
||||
"item_code": qo.items[1].item_code,
|
||||
"rate": 50,
|
||||
"qty": qo.items[1].qty,
|
||||
"docname": qo.items[1].name,
|
||||
},
|
||||
]
|
||||
)
|
||||
update_child_qty_rate("Quotation", trans_item, qo.name)
|
||||
qo.reload()
|
||||
self.assertEqual(qo.items[1].rate, 50)
|
||||
|
||||
def test_update_child_removing_item(self):
|
||||
qo = make_quotation(qty=10)
|
||||
@@ -143,6 +182,10 @@ class TestQuotation(IntegrationTestCase):
|
||||
|
||||
self.assertTrue(quotation.payment_schedule)
|
||||
|
||||
@IntegrationTestCase.change_settings(
|
||||
"Accounts Settings",
|
||||
{"automatically_fetch_payment_terms": 1},
|
||||
)
|
||||
def test_make_sales_order_terms_copied(self):
|
||||
from erpnext.selling.doctype.quotation.quotation import make_sales_order
|
||||
|
||||
@@ -285,7 +328,11 @@ class TestQuotation(IntegrationTestCase):
|
||||
|
||||
@IntegrationTestCase.change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 0},
|
||||
{
|
||||
"add_taxes_from_item_tax_template": 0,
|
||||
"add_taxes_from_taxes_and_charges_template": 0,
|
||||
"automatically_fetch_payment_terms": 1,
|
||||
},
|
||||
)
|
||||
def test_make_sales_order_with_terms(self):
|
||||
from erpnext.selling.doctype.quotation.quotation import make_sales_order
|
||||
@@ -323,10 +370,13 @@ class TestQuotation(IntegrationTestCase):
|
||||
sales_order.save()
|
||||
|
||||
self.assertEqual(sales_order.payment_schedule[0].payment_amount, 8906.00)
|
||||
self.assertEqual(sales_order.payment_schedule[0].due_date, getdate(quotation.transaction_date))
|
||||
self.assertEqual(
|
||||
getdate(sales_order.payment_schedule[0].due_date), getdate(quotation.transaction_date)
|
||||
)
|
||||
self.assertEqual(sales_order.payment_schedule[1].payment_amount, 8906.00)
|
||||
self.assertEqual(
|
||||
sales_order.payment_schedule[1].due_date, getdate(add_days(quotation.transaction_date, 30))
|
||||
getdate(sales_order.payment_schedule[1].due_date),
|
||||
getdate(add_days(quotation.transaction_date, 30)),
|
||||
)
|
||||
|
||||
def test_valid_till_before_transaction_date(self):
|
||||
@@ -1026,6 +1076,56 @@ class TestQuotation(IntegrationTestCase):
|
||||
quotation.reload()
|
||||
self.assertEqual(quotation.status, "Open")
|
||||
|
||||
@IntegrationTestCase.change_settings(
|
||||
"Accounts Settings",
|
||||
{"automatically_fetch_payment_terms": 1},
|
||||
)
|
||||
def test_make_sales_order_with_payment_terms(self):
|
||||
from erpnext.selling.doctype.quotation.quotation import make_sales_order
|
||||
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Payment Terms Template",
|
||||
"template_name": "_Test Payment Terms Template for Quotation",
|
||||
"terms": [
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"invoice_portion": 50.00,
|
||||
"credit_days_based_on": "Day(s) after invoice date",
|
||||
"credit_days": 0,
|
||||
},
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"invoice_portion": 50.00,
|
||||
"credit_days_based_on": "Day(s) after invoice date",
|
||||
"credit_days": 10,
|
||||
},
|
||||
],
|
||||
}
|
||||
).save()
|
||||
|
||||
quotation = make_quotation(qty=10, rate=1000, do_not_submit=1)
|
||||
quotation.transaction_date = add_days(nowdate(), -2)
|
||||
quotation.valid_till = add_days(nowdate(), 10)
|
||||
quotation.update({"payment_terms_template": template.name, "payment_schedule": []})
|
||||
quotation.save()
|
||||
quotation.submit()
|
||||
|
||||
self.assertEqual(quotation.payment_schedule[0].payment_amount, 5000)
|
||||
self.assertEqual(quotation.payment_schedule[1].payment_amount, 5000)
|
||||
self.assertEqual(quotation.payment_schedule[0].due_date, quotation.transaction_date)
|
||||
self.assertEqual(quotation.payment_schedule[1].due_date, add_days(quotation.transaction_date, 10))
|
||||
|
||||
sales_order = make_sales_order(quotation.name)
|
||||
sales_order.transaction_date = nowdate()
|
||||
sales_order.delivery_date = nowdate()
|
||||
sales_order.save()
|
||||
|
||||
self.assertEqual(sales_order.payment_schedule[0].due_date, sales_order.transaction_date)
|
||||
self.assertEqual(sales_order.payment_schedule[1].due_date, add_days(sales_order.transaction_date, 10))
|
||||
self.assertEqual(sales_order.payment_schedule[0].payment_amount, 5000)
|
||||
self.assertEqual(sales_order.payment_schedule[1].payment_amount, 5000)
|
||||
|
||||
|
||||
def enable_calculate_bundle_price(enable=1):
|
||||
selling_settings = frappe.get_doc("Selling Settings")
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"column_break_7",
|
||||
"order_type",
|
||||
"transaction_date",
|
||||
"transaction_time",
|
||||
"delivery_date",
|
||||
"column_break1",
|
||||
"tax_id",
|
||||
@@ -122,6 +123,7 @@
|
||||
"company_contact_person",
|
||||
"payment_schedule_section",
|
||||
"payment_terms_section",
|
||||
"ignore_default_payment_terms_template",
|
||||
"payment_terms_template",
|
||||
"payment_schedule",
|
||||
"terms_section_break",
|
||||
@@ -1724,6 +1726,22 @@
|
||||
"fieldname": "utm_analytics_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "UTM Analytics"
|
||||
},
|
||||
{
|
||||
"default": "Now",
|
||||
"depends_on": "is_internal_customer",
|
||||
"fieldname": "transaction_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Time",
|
||||
"mandatory_depends_on": "is_internal_customer"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "ignore_default_payment_terms_template",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Ignore Default Payment Terms Template",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -1731,7 +1749,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-10 11:55:52.796522",
|
||||
"modified": "2026-03-04 18:04:05.873483",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order",
|
||||
|
||||
@@ -116,6 +116,7 @@ class SalesOrder(SellingController):
|
||||
grand_total: DF.Currency
|
||||
group_same_items: DF.Check
|
||||
has_unit_price_items: DF.Check
|
||||
ignore_default_payment_terms_template: DF.Check
|
||||
ignore_pricing_rule: DF.Check
|
||||
in_words: DF.Data | None
|
||||
incoterm: DF.Link | None
|
||||
@@ -186,6 +187,7 @@ class SalesOrder(SellingController):
|
||||
total_qty: DF.Float
|
||||
total_taxes_and_charges: DF.Currency
|
||||
transaction_date: DF.Date
|
||||
transaction_time: DF.Time | None
|
||||
utm_campaign: DF.Link | None
|
||||
utm_content: DF.Data | None
|
||||
utm_medium: DF.Link | None
|
||||
|
||||
@@ -2647,6 +2647,49 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
|
||||
si2 = make_sales_invoice(so.name)
|
||||
self.assertEqual(si2.items[0].qty, 20)
|
||||
|
||||
@change_settings("Selling Settings", {"validate_selling_price": 1})
|
||||
def test_selling_price_validation_for_manufactured_item(self):
|
||||
"""
|
||||
Unit test to check the selling price validation for manufactured item, without last purchae rate in Item master.
|
||||
"""
|
||||
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
# create a FG Item and RM Item
|
||||
rm_item = make_item(
|
||||
"_Test RM Item for SO selling validation",
|
||||
{"is_stock_item": 1, "valuation_rate": 100, "stock_uom": "Nos"},
|
||||
).name
|
||||
rm_warehouse = create_warehouse("_Test RM SPV Warehouse")
|
||||
fg_item = make_item("_Test FG Item for SO selling validation", {"is_stock_item": 1}).name
|
||||
fg_warehouse = create_warehouse("_Test FG SPV Warehouse")
|
||||
|
||||
# create BOM and inward entry for RM Item
|
||||
bom_no = make_bom(item=fg_item, raw_materials=[rm_item]).name
|
||||
make_stock_entry(item_code=rm_item, target=rm_warehouse, qty=10, rate=100)
|
||||
|
||||
# create a manufacture entry, so system won't update the last purchase rate in Item master.
|
||||
se = make_stock_entry(item_code=fg_item, qty=10, purpose="Manufacture", do_not_save=True)
|
||||
|
||||
se.from_bom = 1
|
||||
se.use_multi_level_bom = 1
|
||||
se.bom_no = bom_no
|
||||
se.fg_completed_qty = 1
|
||||
se.from_warehouse = rm_warehouse
|
||||
se.to_warehouse = fg_warehouse
|
||||
|
||||
se.get_items()
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
# check valuation of FG Item
|
||||
self.assertEqual(se.items[1].valuation_rate, 100)
|
||||
|
||||
# create a SO for FG Item with selling rate than valuation rate.
|
||||
so = make_sales_order(item_code=fg_item, qty=10, rate=50, warehouse=fg_warehouse, do_not_save=1)
|
||||
self.assertRaises(frappe.ValidationError, so.save)
|
||||
|
||||
|
||||
def compare_payment_schedules(doc, doc1, doc2):
|
||||
for index, schedule in enumerate(doc1.get("payment_schedule")):
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"ordered_qty",
|
||||
"planned_qty",
|
||||
"production_plan_qty",
|
||||
"requested_qty",
|
||||
"column_break_69",
|
||||
"work_order_qty",
|
||||
"delivered_qty",
|
||||
@@ -1010,13 +1011,21 @@
|
||||
"fieldtype": "Float",
|
||||
"label": "Finished Good Qty",
|
||||
"mandatory_depends_on": "eval:parent.is_subcontracted"
|
||||
},
|
||||
{
|
||||
"fieldname": "requested_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Requested Qty",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-20 16:39:00.200328",
|
||||
"modified": "2026-02-21 16:39:00.200328",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
|
||||
@@ -80,6 +80,7 @@ class SalesOrderItem(Document):
|
||||
quotation_item: DF.Data | None
|
||||
rate: DF.Currency
|
||||
rate_with_margin: DF.Currency
|
||||
requested_qty: DF.Float
|
||||
reserve_stock: DF.Check
|
||||
returned_qty: DF.Float
|
||||
stock_qty: DF.Float
|
||||
|
||||
@@ -329,7 +329,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-12 10:38:34.605126",
|
||||
"modified": "2026-02-27 00:47:46.003305",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Selling Settings",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"allow_roles": [
|
||||
{
|
||||
"role": "System Manager"
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager"
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager"
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager"
|
||||
}
|
||||
],
|
||||
"creation": "2026-02-24 18:03:53.158438",
|
||||
"docstatus": 0,
|
||||
"doctype": "Module Onboarding",
|
||||
"idx": 0,
|
||||
"is_complete": 0,
|
||||
"modified": "2026-02-24 18:07:36.808560",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"name": "Organization Onboarding",
|
||||
"owner": "Administrator",
|
||||
"steps": [
|
||||
{
|
||||
"step": "Setup Company"
|
||||
},
|
||||
{
|
||||
"step": "Invite Users"
|
||||
},
|
||||
{
|
||||
"step": "Setup Email Account"
|
||||
},
|
||||
{
|
||||
"step": "Setup Role Permissions"
|
||||
},
|
||||
{
|
||||
"step": "Review System Settings"
|
||||
}
|
||||
],
|
||||
"title": "Setup Organization"
|
||||
}
|
||||
20
erpnext/setup/onboarding_step/invite_users/invite_users.json
Normal file
20
erpnext/setup/onboarding_step/invite_users/invite_users.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"action": "Create Entry",
|
||||
"action_label": "Invite Users",
|
||||
"creation": "2026-02-24 18:04:21.585575",
|
||||
"docstatus": 0,
|
||||
"doctype": "Onboarding Step",
|
||||
"idx": 0,
|
||||
"is_complete": 0,
|
||||
"is_single": 0,
|
||||
"is_skipped": 0,
|
||||
"modified": "2026-02-24 18:04:21.585575",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Invite Users",
|
||||
"owner": "Administrator",
|
||||
"reference_document": "User",
|
||||
"show_form_tour": 0,
|
||||
"show_full_form": 0,
|
||||
"title": "Invite Users",
|
||||
"validate_action": 1
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"action": "Update Settings",
|
||||
"action_label": "Review System Settings",
|
||||
"creation": "2026-02-24 18:06:56.781335",
|
||||
"docstatus": 0,
|
||||
"doctype": "Onboarding Step",
|
||||
"idx": 0,
|
||||
"is_complete": 0,
|
||||
"is_single": 1,
|
||||
"is_skipped": 0,
|
||||
"modified": "2026-02-24 18:06:56.781335",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Review System Settings",
|
||||
"owner": "Administrator",
|
||||
"reference_document": "System Settings",
|
||||
"show_form_tour": 0,
|
||||
"show_full_form": 0,
|
||||
"title": "Review System Settings",
|
||||
"validate_action": 0,
|
||||
"value_to_validate": ""
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"action": "Go to Page",
|
||||
"action_label": "Setup Company",
|
||||
"creation": "2026-02-20 11:12:50.373049",
|
||||
"docstatus": 0,
|
||||
"doctype": "Onboarding Step",
|
||||
"idx": 1,
|
||||
"is_complete": 0,
|
||||
"is_single": 0,
|
||||
"is_skipped": 0,
|
||||
"modified": "2026-02-23 21:10:17.680053",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Setup Company",
|
||||
"owner": "Administrator",
|
||||
"path": "company",
|
||||
"reference_document": "Company",
|
||||
"show_form_tour": 0,
|
||||
"show_full_form": 0,
|
||||
"title": "Setup Company",
|
||||
"validate_action": 1
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"action": "Create Entry",
|
||||
"action_label": "Setup Email Account",
|
||||
"creation": "2026-02-24 18:04:39.983155",
|
||||
"docstatus": 0,
|
||||
"doctype": "Onboarding Step",
|
||||
"idx": 0,
|
||||
"is_complete": 0,
|
||||
"is_single": 0,
|
||||
"is_skipped": 0,
|
||||
"modified": "2026-02-24 18:04:39.983155",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Setup Email Account",
|
||||
"owner": "Administrator",
|
||||
"reference_document": "Email Account",
|
||||
"show_form_tour": 0,
|
||||
"show_full_form": 0,
|
||||
"title": "Setup Email Account",
|
||||
"validate_action": 1
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"action": "Go to Page",
|
||||
"action_label": "Setup Role Permissions",
|
||||
"creation": "2026-02-24 18:05:10.485778",
|
||||
"docstatus": 0,
|
||||
"doctype": "Onboarding Step",
|
||||
"idx": 0,
|
||||
"is_complete": 0,
|
||||
"is_single": 0,
|
||||
"is_skipped": 0,
|
||||
"modified": "2026-02-24 18:05:10.485778",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Setup Role Permissions",
|
||||
"owner": "Administrator",
|
||||
"path": "permission-manager",
|
||||
"show_form_tour": 0,
|
||||
"show_full_form": 0,
|
||||
"title": "Setup Role Permissions",
|
||||
"validate_action": 1
|
||||
}
|
||||
@@ -221,6 +221,8 @@ def set_defaults_for_tests():
|
||||
frappe.db.set_default(key, value)
|
||||
frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0)
|
||||
|
||||
frappe.db.set_single_value("Stock Settings", "enable_serial_and_batch_no_for_item", 1)
|
||||
|
||||
|
||||
def insert_record(records):
|
||||
from frappe.desk.page.setup_wizard.setup_wizard import make_records
|
||||
|
||||
@@ -84,7 +84,25 @@ frappe.ui.form.on("Item", {
|
||||
}
|
||||
},
|
||||
|
||||
toggle_has_serial_batch_fields(frm) {
|
||||
let hide_fields = cint(frappe.user_defaults?.enable_serial_and_batch_no_for_item) === 0 ? 1 : 0;
|
||||
|
||||
frm.toggle_display(["serial_no_series", "batch_number_series", "create_new_batch"], !hide_fields);
|
||||
frm.toggle_enable(["has_serial_no", "has_batch_no"], !hide_fields);
|
||||
|
||||
if (hide_fields) {
|
||||
let description = __(
|
||||
"To enable the Serial No and Batch No feature, please check the 'Enable Serial / Batch No for Item' checkbox in Stock Settings."
|
||||
);
|
||||
|
||||
frm.set_df_property("has_serial_no", "description", description);
|
||||
frm.set_df_property("has_batch_no", "description", description);
|
||||
}
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frm.trigger("toggle_has_serial_batch_fields");
|
||||
|
||||
if (frm.doc.is_stock_item) {
|
||||
frm.add_custom_button(
|
||||
__("Stock Balance"),
|
||||
|
||||
@@ -452,6 +452,7 @@
|
||||
"fieldname": "batch_number_series",
|
||||
"fieldtype": "Data",
|
||||
"label": "Batch Number Series",
|
||||
"show_description_on_click": 1,
|
||||
"translatable": 1
|
||||
},
|
||||
{
|
||||
@@ -493,7 +494,8 @@
|
||||
"description": "Example: ABCD.#####\nIf series is set and Serial No is not mentioned in transactions, then automatic serial number will be created based on this series. If you always want to explicitly mention Serial Nos for this item. leave this blank.",
|
||||
"fieldname": "serial_no_series",
|
||||
"fieldtype": "Data",
|
||||
"label": "Serial Number Series"
|
||||
"label": "Serial Number Series",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
@@ -985,7 +987,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-02-05 17:20:35.605734",
|
||||
"modified": "2026-03-05 16:29:31.653447",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -218,6 +218,7 @@ class Item(Document):
|
||||
self.validate_auto_reorder_enabled_in_stock_settings()
|
||||
self.cant_change()
|
||||
self.validate_item_tax_net_rate_range()
|
||||
self.validate_allow_to_set_serial_batch()
|
||||
|
||||
if not self.is_new():
|
||||
self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group")
|
||||
@@ -226,6 +227,18 @@ class Item(Document):
|
||||
self.update_variants()
|
||||
self.update_item_price()
|
||||
|
||||
def validate_allow_to_set_serial_batch(self):
|
||||
if not self.has_serial_no and not self.has_batch_no:
|
||||
return
|
||||
|
||||
if not frappe.db.get_single_value("Stock Settings", "enable_serial_and_batch_no_for_item"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please check the 'Enable Serial and Batch No for Item' checkbox in the {0} to set Serial No or Batch No for the item."
|
||||
).format(get_link_to_form("Stock Settings", "Stock Settings")),
|
||||
title=_("Serial and Batch No for Item Disabled"),
|
||||
)
|
||||
|
||||
def validate_description(self):
|
||||
"""Clean HTML description if set"""
|
||||
if (
|
||||
|
||||
@@ -14,6 +14,10 @@ from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
|
||||
class IncorrectCompanyValidationError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class LandedCostVoucher(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
@@ -75,6 +79,7 @@ class LandedCostVoucher(Document):
|
||||
self.check_mandatory()
|
||||
self.validate_receipt_documents()
|
||||
self.validate_line_items()
|
||||
self.validate_expense_accounts()
|
||||
init_landed_taxes_and_totals(self)
|
||||
self.set_total_taxes_and_charges()
|
||||
if not self.get("items"):
|
||||
@@ -116,11 +121,28 @@ class LandedCostVoucher(Document):
|
||||
receipt_documents = []
|
||||
|
||||
for d in self.get("purchase_receipts"):
|
||||
docstatus = frappe.db.get_value(d.receipt_document_type, d.receipt_document, "docstatus")
|
||||
docstatus, company = frappe.get_cached_value(
|
||||
d.receipt_document_type, d.receipt_document, ["docstatus", "company"]
|
||||
)
|
||||
if docstatus != 1:
|
||||
msg = f"Row {d.idx}: {d.receipt_document_type} {frappe.bold(d.receipt_document)} must be submitted"
|
||||
frappe.throw(_(msg), title=_("Invalid Document"))
|
||||
|
||||
if company != self.company:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {0}: {1} {2} is linked to company {3}. Please select a document belonging to company {4}."
|
||||
).format(
|
||||
d.idx,
|
||||
d.receipt_document_type,
|
||||
frappe.bold(d.receipt_document),
|
||||
frappe.bold(company),
|
||||
frappe.bold(self.company),
|
||||
),
|
||||
title=_("Incorrect Company"),
|
||||
exc=IncorrectCompanyValidationError,
|
||||
)
|
||||
|
||||
if d.receipt_document_type == "Purchase Invoice":
|
||||
update_stock = frappe.db.get_value(
|
||||
d.receipt_document_type, d.receipt_document, "update_stock"
|
||||
@@ -152,6 +174,24 @@ class LandedCostVoucher(Document):
|
||||
_("Row {0}: Cost center is required for an item {1}").format(item.idx, item.item_code)
|
||||
)
|
||||
|
||||
def validate_expense_accounts(self):
|
||||
for t in self.taxes:
|
||||
company = frappe.get_cached_value("Account", t.expense_account, "company")
|
||||
|
||||
if company != self.company:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {0}: Expense Account {1} is linked to company {2}. Please select an account belonging to company {3}."
|
||||
).format(
|
||||
t.idx,
|
||||
frappe.bold(t.expense_account),
|
||||
frappe.bold(company),
|
||||
frappe.bold(self.company),
|
||||
),
|
||||
title=_("Incorrect Account"),
|
||||
exc=IncorrectCompanyValidationError,
|
||||
)
|
||||
|
||||
def set_total_taxes_and_charges(self):
|
||||
self.total_taxes_and_charges = sum(flt(d.base_amount) for d in self.get("taxes"))
|
||||
|
||||
|
||||
@@ -178,6 +178,39 @@ class TestLandedCostVoucher(IntegrationTestCase):
|
||||
self.assertEqual(last_sle.qty_after_transaction, last_sle_after_landed_cost.qty_after_transaction)
|
||||
self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 50.0)
|
||||
|
||||
def test_lcv_validates_company(self):
|
||||
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
|
||||
IncorrectCompanyValidationError,
|
||||
)
|
||||
|
||||
company_a = "_Test Company"
|
||||
company_b = "_Test Company with perpetual inventory"
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company=company_a,
|
||||
warehouse="Stores - _TC",
|
||||
qty=1,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
lcv = make_landed_cost_voucher(
|
||||
company=company_b,
|
||||
receipt_document_type="Purchase Receipt",
|
||||
receipt_document=pr.name,
|
||||
charges=50,
|
||||
do_not_save=True,
|
||||
)
|
||||
|
||||
self.assertRaises(IncorrectCompanyValidationError, lcv.validate_receipt_documents)
|
||||
lcv.company = company_a
|
||||
|
||||
self.assertRaises(IncorrectCompanyValidationError, lcv.validate_expense_accounts)
|
||||
lcv.taxes[0].expense_account = get_expense_account(company_a)
|
||||
|
||||
lcv.save()
|
||||
distribute_landed_cost_on_items(lcv)
|
||||
lcv.submit()
|
||||
|
||||
def test_landed_cost_voucher_for_zero_purchase_rate(self):
|
||||
"Test impact of LCV on future stock balances."
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -1260,6 +1293,7 @@ def make_landed_cost_voucher(**args):
|
||||
lcv = frappe.new_doc("Landed Cost Voucher")
|
||||
lcv.company = args.company or "_Test Company"
|
||||
lcv.distribute_charges_based_on = args.distribute_charges_based_on or "Amount"
|
||||
expense_account = get_expense_account(args.company or "_Test Company")
|
||||
|
||||
lcv.set(
|
||||
"purchase_receipts",
|
||||
@@ -1280,7 +1314,7 @@ def make_landed_cost_voucher(**args):
|
||||
[
|
||||
{
|
||||
"description": "Shipping Charges",
|
||||
"expense_account": args.expense_account or "Expenses Included In Valuation - TCP1",
|
||||
"expense_account": args.expense_account or expense_account,
|
||||
"amount": args.charges,
|
||||
}
|
||||
],
|
||||
@@ -1300,6 +1334,7 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company,
|
||||
lcv = frappe.new_doc("Landed Cost Voucher")
|
||||
lcv.company = company
|
||||
lcv.distribute_charges_based_on = "Amount"
|
||||
expense_account = get_expense_account(company)
|
||||
|
||||
lcv.set(
|
||||
"purchase_receipts",
|
||||
@@ -1319,7 +1354,7 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company,
|
||||
[
|
||||
{
|
||||
"description": "Insurance Charges",
|
||||
"expense_account": "Expenses Included In Valuation - TCP1",
|
||||
"expense_account": expense_account,
|
||||
"amount": charges,
|
||||
}
|
||||
],
|
||||
@@ -1334,6 +1369,11 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company,
|
||||
return lcv
|
||||
|
||||
|
||||
def get_expense_account(company):
|
||||
company_abbr = frappe.get_cached_value("Company", company, "abbr")
|
||||
return f"Expenses Included In Valuation - {company_abbr}"
|
||||
|
||||
|
||||
def distribute_landed_cost_on_items(lcv):
|
||||
based_on = lcv.distribute_charges_based_on.lower()
|
||||
total = sum(flt(d.get(based_on)) for d in lcv.get("items"))
|
||||
|
||||
@@ -90,7 +90,7 @@ class MaterialRequest(BuyingController):
|
||||
{
|
||||
"source_dt": "Material Request Item",
|
||||
"target_dt": "Sales Order Item",
|
||||
"target_field": "ordered_qty",
|
||||
"target_field": "requested_qty",
|
||||
"target_parent_dt": "Sales Order",
|
||||
"target_parent_field": "",
|
||||
"join_field": "sales_order_item",
|
||||
@@ -280,6 +280,8 @@ class MaterialRequest(BuyingController):
|
||||
def on_cancel(self):
|
||||
self.update_requested_qty_in_production_plan(cancel=True)
|
||||
self.update_requested_qty()
|
||||
if self.material_request_type == "Purchase":
|
||||
self.update_prevdoc_status()
|
||||
|
||||
def get_mr_items_ordered_qty(self, mr_items):
|
||||
mr_items_ordered_qty = {}
|
||||
@@ -330,7 +332,8 @@ class MaterialRequest(BuyingController):
|
||||
|
||||
if mr_qty_allowance:
|
||||
allowed_qty = flt(
|
||||
(d.qty + (d.qty * (mr_qty_allowance / 100))), d.precision("ordered_qty")
|
||||
(d.stock_qty + (d.stock_qty * (mr_qty_allowance / 100))),
|
||||
d.precision("ordered_qty"),
|
||||
)
|
||||
|
||||
if d.ordered_qty and flt(d.ordered_qty, precision) > flt(allowed_qty, precision):
|
||||
|
||||
@@ -119,6 +119,8 @@ frappe.ui.form.on("Pick List", {
|
||||
refresh: (frm) => {
|
||||
frm.trigger("add_get_items_button");
|
||||
frm.trigger("update_warehouse_property");
|
||||
erpnext.toggle_serial_batch_fields(frm);
|
||||
|
||||
if (frm.doc.docstatus === 1) {
|
||||
const status_completed = frm.doc.status === "Completed";
|
||||
|
||||
|
||||
@@ -1587,7 +1587,7 @@ def update_common_item_properties(item, location):
|
||||
item.item_code = location.item_code
|
||||
item.s_warehouse = location.warehouse
|
||||
item.transfer_qty = location.picked_qty
|
||||
item.qty = location.qty
|
||||
item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty"))
|
||||
item.uom = location.uom
|
||||
item.conversion_factor = location.conversion_factor
|
||||
item.stock_uom = location.stock_uom
|
||||
|
||||
@@ -1246,7 +1246,9 @@ def get_billed_amount_against_po(po_items):
|
||||
def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate=False):
|
||||
# Update Billing % based on pending accepted qty
|
||||
buying_settings = frappe.get_single("Buying Settings")
|
||||
over_billing_allowance = frappe.get_single_value("Accounts Settings", "over_billing_allowance")
|
||||
over_billing_allowance, role_allowed_to_over_bill = frappe.get_single_value(
|
||||
"Accounts Settings", ["over_billing_allowance", "role_allowed_to_over_bill"]
|
||||
)
|
||||
|
||||
total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0
|
||||
item_wise_returned_qty = get_item_wise_returned_qty(pr_doc)
|
||||
@@ -1304,7 +1306,10 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
|
||||
item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
|
||||
elif amount and item.billed_amt > amount:
|
||||
per_over_billed = (flt(item.billed_amt / amount, 2) * 100) - 100
|
||||
if per_over_billed > over_billing_allowance:
|
||||
if (
|
||||
per_over_billed > over_billing_allowance
|
||||
and role_allowed_to_over_bill not in frappe.get_roles()
|
||||
):
|
||||
frappe.throw(
|
||||
_("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format(
|
||||
item.name, frappe.bold(item.item_code), per_over_billed - over_billing_allowance
|
||||
|
||||
@@ -142,7 +142,9 @@ class TestQualityInspection(IntegrationTestCase):
|
||||
inspection_type = "Outgoing"
|
||||
for item in dn.items:
|
||||
item.sample_size = item.qty
|
||||
quality_inspections = make_quality_inspections(dn.doctype, dn.name, dn.items, inspection_type)
|
||||
quality_inspections = make_quality_inspections(
|
||||
dn.company, dn.doctype, dn.name, dn.items, inspection_type
|
||||
)
|
||||
self.assertEqual(len(dn.items), len(quality_inspections))
|
||||
|
||||
# cleanup
|
||||
|
||||
@@ -570,7 +570,20 @@ def run_parallel_reposting():
|
||||
|
||||
riv_entries = get_repost_item_valuation_entries()
|
||||
|
||||
rq_jobs = frappe.get_all(
|
||||
"RQ Job",
|
||||
fields=["arguments"],
|
||||
filters={
|
||||
"status": ("like", "%started%"),
|
||||
"job_name": "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.execute_reposting_entry",
|
||||
},
|
||||
)
|
||||
|
||||
for row in riv_entries:
|
||||
if rq_jobs:
|
||||
if job_running_for_entry(row.name, rq_jobs):
|
||||
continue
|
||||
|
||||
if row.based_on != "Item and Warehouse" or row.repost_only_accounting_ledgers:
|
||||
execute_reposting_entry(row.name)
|
||||
continue
|
||||
@@ -719,3 +732,19 @@ def get_existing_reposting_only_gl_entries(reposting_reference):
|
||||
reposting_map[key] = d.reposting_reference
|
||||
|
||||
return reposting_map
|
||||
|
||||
|
||||
def job_running_for_entry(reposting_entry, rq_jobs):
|
||||
for job in rq_jobs:
|
||||
if not job.arguments:
|
||||
continue
|
||||
|
||||
try:
|
||||
job_args = json.loads(job.arguments)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
if isinstance(job_args, dict) and job_args.get("kwargs", {}).get("name") == reposting_entry:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -107,6 +107,7 @@ class SerialandBatchBundle(Document):
|
||||
self.autoname()
|
||||
|
||||
def validate(self):
|
||||
self.validate_allow_to_set_serial_batch()
|
||||
if self.docstatus == 1 and self.voucher_detail_no:
|
||||
self.validate_voucher_detail_no()
|
||||
|
||||
@@ -143,6 +144,15 @@ class SerialandBatchBundle(Document):
|
||||
self.calculate_qty_and_amount()
|
||||
self.set_child_details()
|
||||
|
||||
def validate_allow_to_set_serial_batch(self):
|
||||
if not frappe.db.get_single_value("Stock Settings", "enable_serial_and_batch_no_for_item"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please check the 'Enable Serial and Batch No for Item' checkbox in the {0} to make Serial and Batch Bundle for the item."
|
||||
).format(get_link_to_form("Stock Settings", "Stock Settings")),
|
||||
title=_("Serial and Batch No for Item Disabled"),
|
||||
)
|
||||
|
||||
def validate_serial_no_status(self):
|
||||
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
|
||||
invalid_serial_nos = frappe.get_all(
|
||||
@@ -717,10 +727,13 @@ class SerialandBatchBundle(Document):
|
||||
if rate is None and child_table in ["Delivery Note Item", "Sales Invoice Item"]:
|
||||
rate = frappe.db.get_value(
|
||||
"Packed Item",
|
||||
self.voucher_detail_no,
|
||||
{"parent_detail_docname": self.voucher_detail_no, "item_code": self.item_code},
|
||||
"incoming_rate",
|
||||
)
|
||||
|
||||
if rate is None:
|
||||
rate = frappe.db.get_value("Packed Item", self.voucher_detail_no, "incoming_rate")
|
||||
|
||||
if rate is not None:
|
||||
is_packed_item = True
|
||||
|
||||
@@ -787,6 +800,9 @@ class SerialandBatchBundle(Document):
|
||||
if not self.voucher_detail_no or self.voucher_detail_no != row.name:
|
||||
values_to_set["voucher_detail_no"] = row.name
|
||||
|
||||
if row.get("doctype") == "Packed Item" and row.get("parent_detail_docname"):
|
||||
values_to_set["voucher_detail_no"] = row.get("parent_detail_docname")
|
||||
|
||||
if parent.get("posting_date") and parent.get("posting_time"):
|
||||
posting_datetime = combine_datetime(parent.posting_date, parent.posting_time)
|
||||
if not self.posting_datetime or self.posting_datetime != posting_datetime:
|
||||
@@ -1325,7 +1341,21 @@ class SerialandBatchBundle(Document):
|
||||
)
|
||||
|
||||
if not vouchers and self.voucher_type == "Delivery Note":
|
||||
frappe.db.set_value("Packed Item", self.voucher_detail_no, "serial_and_batch_bundle", None)
|
||||
if frappe.db.exists("Packed Item", self.voucher_detail_no):
|
||||
frappe.db.set_value("Packed Item", self.voucher_detail_no, "serial_and_batch_bundle", None)
|
||||
else:
|
||||
packed_items = frappe.get_all(
|
||||
"Packed Item",
|
||||
filters={
|
||||
"parent_detail_docname": self.voucher_detail_no,
|
||||
"serial_and_batch_bundle": self.name,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for packed_item in packed_items:
|
||||
frappe.db.set_value("Packed Item", packed_item, "serial_and_batch_bundle", None)
|
||||
|
||||
return
|
||||
|
||||
for voucher in vouchers:
|
||||
|
||||
@@ -245,6 +245,7 @@ frappe.ui.form.on("Stock Entry", {
|
||||
refresh: function (frm) {
|
||||
frm.trigger("get_items_from_transit_entry");
|
||||
frm.trigger("toggle_warehouse_fields");
|
||||
erpnext.toggle_serial_batch_fields(frm);
|
||||
|
||||
if (!frm.doc.docstatus && !frm.doc.subcontracting_inward_order) {
|
||||
frm.trigger("validate_purpose_consumption");
|
||||
@@ -930,10 +931,6 @@ frappe.ui.form.on("Stock Entry Detail", {
|
||||
);
|
||||
},
|
||||
|
||||
qty(frm, cdt, cdn) {
|
||||
frm.events.set_rate_and_fg_qty(frm, cdt, cdn);
|
||||
},
|
||||
|
||||
conversion_factor(frm, cdt, cdn) {
|
||||
frm.events.set_rate_and_fg_qty(frm, cdt, cdn);
|
||||
},
|
||||
|
||||
@@ -236,6 +236,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_uom_is_integer("stock_uom", "transfer_qty")
|
||||
self.validate_warehouse()
|
||||
self.validate_warehouse_of_sabb()
|
||||
self.validate_work_order()
|
||||
self.validate_bom()
|
||||
self.set_process_loss_qty()
|
||||
|
||||
@@ -2415,6 +2415,54 @@ class TestStockEntry(IntegrationTestCase):
|
||||
frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 5)).submit()
|
||||
frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 5)).submit()
|
||||
|
||||
def test_qi_creation_with_naming_rule_company_condition(self):
|
||||
"""
|
||||
Unit test case to check the document naming rule with company condition
|
||||
For Quality Inspection, when created from Stock Entry.
|
||||
"""
|
||||
from erpnext.accounts.report.trial_balance.test_trial_balance import create_company
|
||||
from erpnext.controllers.stock_controller import make_quality_inspections
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
# create a separate company to handle document naming rule with company condition
|
||||
qc_company = create_company(company_name="Test Quality Company")
|
||||
|
||||
# create document naming rule based on that for Quality Inspection Doctype
|
||||
qc_naming_rule = frappe.new_doc(
|
||||
"Document Naming Rule", document_type="Quality Inspection", prefix="NQC.-ST-", prefix_digits=5
|
||||
)
|
||||
qc_naming_rule.append("conditions", {"field": "company", "condition": "=", "value": qc_company})
|
||||
qc_naming_rule.save()
|
||||
|
||||
warehouse = create_warehouse(warehouse_name="Test QI Warehouse", company=qc_company)
|
||||
item = create_item(
|
||||
item_code="Test QI DNR Item",
|
||||
is_stock_item=1,
|
||||
)
|
||||
|
||||
# create inward stock entry
|
||||
stock_entry = make_stock_entry(
|
||||
item_code=item.item_code,
|
||||
target=warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
inspection_required=True,
|
||||
do_not_submit=True,
|
||||
)
|
||||
|
||||
# create QI from Stock Entry and check the naming series generated.
|
||||
qi = make_quality_inspections(
|
||||
stock_entry.company,
|
||||
stock_entry.doctype,
|
||||
stock_entry.name,
|
||||
stock_entry.as_dict().get("items"),
|
||||
"Incoming",
|
||||
)
|
||||
self.assertEqual(qi[0], "NQC-ST-00001")
|
||||
|
||||
# delete naming rule
|
||||
frappe.delete_doc("Document Naming Rule", qc_naming_rule.name)
|
||||
|
||||
|
||||
def make_serialized_item(self, **args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
@@ -498,7 +498,8 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Purchase Receipt",
|
||||
"options": "Purchase Receipt",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
@@ -660,7 +661,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-16 11:50:50.573443",
|
||||
"modified": "2026-03-02 14:05:23.116017",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry Detail",
|
||||
|
||||
@@ -76,6 +76,8 @@ frappe.ui.form.on("Stock Reconciliation", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
erpnext.toggle_serial_batch_fields(frm);
|
||||
|
||||
if (frm.doc.docstatus < 1) {
|
||||
frm.add_custom_button(__("Fetch Items from Warehouse"), function () {
|
||||
frm.events.get_items(frm);
|
||||
|
||||
@@ -523,9 +523,9 @@ class StockReconciliation(StockController):
|
||||
if abs(difference_amount) > 0:
|
||||
return True
|
||||
|
||||
float_precision = frappe.db.get_default("float_precision") or 3
|
||||
item_dict["rate"] = flt(item_dict.get("rate"), float_precision)
|
||||
item.valuation_rate = flt(item.valuation_rate, float_precision) if item.valuation_rate else None
|
||||
rate_precision = item.precision("valuation_rate")
|
||||
item_dict["rate"] = flt(item_dict.get("rate"), rate_precision)
|
||||
item.valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None
|
||||
if (
|
||||
(item.qty is None or item.qty == item_dict.get("qty"))
|
||||
and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate"))
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"allow_internal_transfer_at_arms_length_price",
|
||||
"validate_material_transfer_warehouses",
|
||||
"serial_and_batch_item_settings_tab",
|
||||
"enable_serial_and_batch_no_for_item",
|
||||
"section_break_7",
|
||||
"allow_existing_serial_no",
|
||||
"do_not_use_batchwise_valuation",
|
||||
@@ -48,9 +49,8 @@
|
||||
"use_serial_batch_fields",
|
||||
"do_not_update_serial_batch_on_creation_of_auto_bundle",
|
||||
"allow_negative_stock_for_batch",
|
||||
"serial_and_batch_bundle_section",
|
||||
"set_serial_and_batch_bundle_naming_based_on_naming_series",
|
||||
"section_break_gnhq",
|
||||
"set_serial_and_batch_bundle_naming_based_on_naming_series",
|
||||
"use_naming_series",
|
||||
"column_break_wslv",
|
||||
"naming_series_prefix",
|
||||
@@ -158,6 +158,7 @@
|
||||
"label": "Convert Item Description to Clean HTML in Transactions"
|
||||
},
|
||||
{
|
||||
"depends_on": "enable_serial_and_batch_no_for_item",
|
||||
"fieldname": "section_break_7",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Serial & Batch Item Settings"
|
||||
@@ -487,11 +488,6 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Reserve Stock"
|
||||
},
|
||||
{
|
||||
"fieldname": "serial_and_batch_bundle_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Serial and Batch Bundle"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "set_serial_and_batch_bundle_naming_based_on_naming_series",
|
||||
@@ -499,6 +495,7 @@
|
||||
"label": "Set Serial and Batch Bundle Naming Based on Naming Series"
|
||||
},
|
||||
{
|
||||
"depends_on": "enable_serial_and_batch_no_for_item",
|
||||
"fieldname": "section_break_gnhq",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
@@ -554,6 +551,11 @@
|
||||
"fieldname": "allow_negative_stock_for_batch",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Negative Stock for Batch"
|
||||
},
|
||||
{
|
||||
"fieldname": "enable_serial_and_batch_no_for_item",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Serial / Batch No for Item"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
@@ -562,7 +564,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-25 09:56:34.105949",
|
||||
"modified": "2026-02-25 10:56:34.105949",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Settings",
|
||||
|
||||
@@ -47,6 +47,7 @@ class StockSettings(Document):
|
||||
disable_serial_no_and_batch_selector: DF.Check
|
||||
do_not_update_serial_batch_on_creation_of_auto_bundle: DF.Check
|
||||
do_not_use_batchwise_valuation: DF.Check
|
||||
enable_serial_and_batch_no_for_item: DF.Check
|
||||
enable_stock_reservation: DF.Check
|
||||
item_group: DF.Link | None
|
||||
item_naming_by: DF.Literal["Item Code", "Naming Series"]
|
||||
@@ -82,6 +83,7 @@ class StockSettings(Document):
|
||||
"default_warehouse",
|
||||
"set_qty_in_transactions_based_on_serial_no_input",
|
||||
"use_serial_batch_fields",
|
||||
"enable_serial_and_batch_no_for_item",
|
||||
"set_serial_and_batch_bundle_naming_based_on_naming_series",
|
||||
]:
|
||||
frappe.db.set_default(key, self.get(key, ""))
|
||||
@@ -104,6 +106,7 @@ class StockSettings(Document):
|
||||
)
|
||||
|
||||
self.validate_warehouses()
|
||||
self.validate_serial_and_batch_no_settings()
|
||||
self.cant_change_valuation_method()
|
||||
self.validate_clean_description_html()
|
||||
self.validate_pending_reposts()
|
||||
@@ -112,6 +115,25 @@ class StockSettings(Document):
|
||||
self.change_precision_for_for_sales()
|
||||
self.change_precision_for_purchase()
|
||||
|
||||
def validate_serial_and_batch_no_settings(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if not doc_before_save:
|
||||
return
|
||||
|
||||
if doc_before_save.enable_serial_and_batch_no_for_item == self.enable_serial_and_batch_no_for_item:
|
||||
return
|
||||
|
||||
if (
|
||||
doc_before_save.enable_serial_and_batch_no_for_item
|
||||
and not self.enable_serial_and_batch_no_for_item
|
||||
):
|
||||
if frappe.get_all("Serial and Batch Bundle", filters={"docstatus": 1}, limit=1, pluck="name"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot disable Serial and Batch No for Item, as there are existing records for serial / batch."
|
||||
)
|
||||
)
|
||||
|
||||
def validate_warehouses(self):
|
||||
warehouse_fields = ["default_warehouse", "sample_retention_warehouse"]
|
||||
for field in warehouse_fields:
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, TypedDict
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce
|
||||
from frappe.query_builder.functions import Coalesce, Count
|
||||
from frappe.utils import add_days, cint, date_diff, flt, getdate
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
|
||||
@@ -165,6 +165,7 @@ class StockBalanceReport:
|
||||
sle.serial_no,
|
||||
sle.serial_and_batch_bundle,
|
||||
sle.has_serial_no,
|
||||
sle.voucher_detail_no,
|
||||
item_table.item_group,
|
||||
item_table.stock_uom,
|
||||
item_table.item_name,
|
||||
@@ -190,6 +191,8 @@ class StockBalanceReport:
|
||||
if self.filters.get("show_stock_ageing_data"):
|
||||
self.sle_entries = self.sle_query.run(as_dict=True)
|
||||
|
||||
self.prepare_stock_reco_voucher_wise_count()
|
||||
|
||||
# HACK: This is required to avoid causing db query in flt
|
||||
_system_settings = frappe.get_cached_doc("System Settings")
|
||||
with frappe.db.unbuffered_cursor():
|
||||
@@ -207,6 +210,71 @@ class StockBalanceReport:
|
||||
self.item_warehouse_map, self.float_precision, self.inventory_dimensions
|
||||
)
|
||||
|
||||
def prepare_stock_reco_voucher_wise_count(self):
|
||||
self.stock_reco_voucher_wise_count = frappe._dict()
|
||||
|
||||
doctype = frappe.qb.DocType("Stock Ledger Entry")
|
||||
item = frappe.qb.DocType("Item")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(doctype)
|
||||
.inner_join(item)
|
||||
.on(doctype.item_code == item.name)
|
||||
.select(doctype.voucher_detail_no, Count(doctype.name).as_("count"))
|
||||
.where(
|
||||
(doctype.voucher_type == "Stock Reconciliation")
|
||||
& (doctype.docstatus < 2)
|
||||
& (doctype.is_cancelled == 0)
|
||||
& (item.has_serial_no == 1)
|
||||
)
|
||||
.groupby(doctype.voucher_detail_no)
|
||||
)
|
||||
|
||||
if items := self.filters.item_code:
|
||||
if isinstance(items, str):
|
||||
items = [items]
|
||||
|
||||
query = query.where(item.name.isin(items))
|
||||
|
||||
if self.filters.item_group:
|
||||
childrens = []
|
||||
childrens.append(self.filters.item_group)
|
||||
if item_group_childrens := get_descendants_of(
|
||||
"Item Group", self.filters.item_group, ignore_permissions=True
|
||||
):
|
||||
childrens.extend(item_group_childrens)
|
||||
|
||||
if childrens:
|
||||
query = query.where(item.item_group.isin(childrens))
|
||||
|
||||
if warehouses := self.filters.get("warehouse"):
|
||||
if isinstance(warehouses, str):
|
||||
warehouses = [warehouses]
|
||||
|
||||
childrens = []
|
||||
for warehouse in warehouses:
|
||||
childrens.append(warehouse)
|
||||
if warehouse_childrens := get_descendants_of("Warehouse", warehouse, ignore_permissions=True):
|
||||
childrens.extend(warehouse_childrens)
|
||||
|
||||
if childrens:
|
||||
query = query.where(doctype.warehouse.isin(childrens))
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
if not data:
|
||||
return
|
||||
|
||||
for row in data:
|
||||
if row.count != 1:
|
||||
continue
|
||||
|
||||
sr_item = frappe.db.get_value(
|
||||
"Stock Reconciliation Item", row.voucher_detail_no, ["current_qty", "qty"], as_dict=True
|
||||
)
|
||||
|
||||
if sr_item.qty and sr_item.current_qty:
|
||||
self.stock_reco_voucher_wise_count[row.voucher_detail_no] = sr_item.current_qty
|
||||
|
||||
def prepare_new_data(self):
|
||||
if self.filters.get("show_stock_ageing_data"):
|
||||
self.filters["show_warehouse_wise_stock"] = True
|
||||
@@ -283,9 +351,14 @@ class StockBalanceReport:
|
||||
qty_dict[field] = entry.get(field)
|
||||
|
||||
if entry.voucher_type == "Stock Reconciliation" and (
|
||||
not entry.batch_no and not entry.serial_no and not entry.serial_and_batch_bundle
|
||||
not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle
|
||||
):
|
||||
qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty)
|
||||
if entry.serial_no and entry.voucher_detail_no in self.stock_reco_voucher_wise_count:
|
||||
qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0)
|
||||
qty_dict.bal_qty = 0.0
|
||||
qty_diff = flt(entry.actual_qty)
|
||||
else:
|
||||
qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty)
|
||||
else:
|
||||
qty_diff = flt(entry.actual_qty)
|
||||
|
||||
|
||||
@@ -27,10 +27,23 @@ def execute(filters=None):
|
||||
items = get_items(filters)
|
||||
sl_entries = get_stock_ledger_entries(filters, items)
|
||||
item_details = get_item_details(items, sl_entries, include_uom)
|
||||
|
||||
inv_dimension_key = []
|
||||
inv_dimension_wise_value = get_inv_dimension_wise_value(filters)
|
||||
if inv_dimension_wise_value:
|
||||
for key in inv_dimension_wise_value:
|
||||
value = inv_dimension_wise_value[key]
|
||||
if isinstance(value, list):
|
||||
inv_dimension_key.extend(value)
|
||||
else:
|
||||
inv_dimension_key.append(value)
|
||||
|
||||
if filters.get("batch_no"):
|
||||
opening_row = get_opening_balance_from_batch(filters, columns, sl_entries)
|
||||
elif inv_dimension_wise_value:
|
||||
opening_row = get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value)
|
||||
else:
|
||||
opening_row = get_opening_balance(filters, columns, sl_entries)
|
||||
opening_row = get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value)
|
||||
|
||||
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
|
||||
bundle_details = {}
|
||||
@@ -50,12 +63,16 @@ def execute(filters=None):
|
||||
stock_value = opening_row.get("stock_value")
|
||||
|
||||
available_serial_nos = {}
|
||||
inventory_dimension_filters_applied = check_inventory_dimension_filters_applied(filters)
|
||||
|
||||
batch_balance_dict = frappe._dict({})
|
||||
if actual_qty and filters.get("batch_no"):
|
||||
batch_balance_dict[filters.batch_no] = [actual_qty, stock_value]
|
||||
|
||||
inv_dimension_wise_dict = frappe._dict({})
|
||||
set_opening_row_for_inv_dimension(
|
||||
inv_dimension_wise_dict, filters, inv_dimension_key=inv_dimension_key, opening_row=opening_row
|
||||
)
|
||||
|
||||
for sle in sl_entries:
|
||||
item_detail = item_details[sle.item_code]
|
||||
|
||||
@@ -64,7 +81,10 @@ def execute(filters=None):
|
||||
data.extend(get_segregated_bundle_entries(sle, bundle_info, batch_balance_dict, filters))
|
||||
continue
|
||||
|
||||
if filters.get("batch_no") or inventory_dimension_filters_applied:
|
||||
if inv_dimension_key:
|
||||
set_balance_value_for_inv_dimesion(inv_dimension_key, inv_dimension_wise_dict, sle)
|
||||
|
||||
if filters.get("batch_no"):
|
||||
actual_qty += flt(sle.actual_qty, precision)
|
||||
stock_value += sle.stock_value_difference
|
||||
if sle.batch_no:
|
||||
@@ -103,6 +123,50 @@ def execute(filters=None):
|
||||
return columns, data
|
||||
|
||||
|
||||
def set_opening_row_for_inv_dimension(
|
||||
inv_dimension_wise_dict, filters, inv_dimension_key=None, opening_row=None
|
||||
):
|
||||
if (
|
||||
not inv_dimension_key
|
||||
or not opening_row
|
||||
or not filters.get("item_code")
|
||||
or not filters.get("warehouse")
|
||||
):
|
||||
return
|
||||
|
||||
if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1:
|
||||
return
|
||||
|
||||
if inv_dimension_key and opening_row and filters.get("item_code") and filters.get("warehouse"):
|
||||
new_key = copy.deepcopy(inv_dimension_key)
|
||||
new_key.extend([filters.item_code[0], filters.warehouse[0]])
|
||||
|
||||
opening_key = tuple(new_key)
|
||||
inv_dimension_wise_dict[opening_key] = {
|
||||
"qty_after_transaction": flt(opening_row.get("qty_after_transaction")),
|
||||
"dimension_stock_value": flt(opening_row.get("stock_value")),
|
||||
}
|
||||
|
||||
|
||||
def set_balance_value_for_inv_dimesion(inv_dimension_key, inv_dimension_wise_dict, sle):
|
||||
new_key = copy.deepcopy(inv_dimension_key)
|
||||
new_key.extend([sle.item_code, sle.warehouse])
|
||||
new_key = tuple(new_key)
|
||||
|
||||
if new_key not in inv_dimension_wise_dict:
|
||||
inv_dimension_wise_dict[new_key] = {"qty_after_transaction": 0, "dimension_stock_value": 0}
|
||||
|
||||
inv_dimesion_value = inv_dimension_wise_dict[new_key]
|
||||
inv_dimesion_value["qty_after_transaction"] += sle.actual_qty
|
||||
inv_dimesion_value["dimension_stock_value"] += sle.stock_value_difference
|
||||
sle.update(
|
||||
{
|
||||
"qty_after_transaction": inv_dimesion_value["qty_after_transaction"],
|
||||
"stock_value": inv_dimesion_value["dimension_stock_value"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict, filters):
|
||||
segregated_entries = []
|
||||
qty_before_transaction = sle.qty_after_transaction - sle.actual_qty
|
||||
@@ -605,19 +669,26 @@ def get_opening_balance_from_batch(filters, columns, sl_entries):
|
||||
}
|
||||
|
||||
|
||||
def get_opening_balance(filters, columns, sl_entries):
|
||||
def get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value=None):
|
||||
if not (filters.item_code and filters.warehouse and filters.from_date):
|
||||
return
|
||||
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
|
||||
project = None
|
||||
if filters.get("project") and not frappe.get_all(
|
||||
"Inventory Dimension", filters={"reference_document": "Project"}
|
||||
):
|
||||
project = filters.get("project")
|
||||
|
||||
last_entry = get_previous_sle(
|
||||
{
|
||||
"item_code": filters.item_code,
|
||||
"warehouse_condition": get_warehouse_condition(filters.warehouse),
|
||||
"posting_date": filters.from_date,
|
||||
"posting_time": "00:00:00",
|
||||
}
|
||||
"project": project,
|
||||
},
|
||||
)
|
||||
|
||||
# check if any SLEs are actually Opening Stock Reconciliation
|
||||
@@ -689,9 +760,75 @@ def get_item_group_condition(item_group, item_table=None):
|
||||
where ig.lft >= {item_group_details.lft} and ig.rgt <= {item_group_details.rgt} and item.item_group = ig.name)"
|
||||
|
||||
|
||||
def check_inventory_dimension_filters_applied(filters) -> bool:
|
||||
def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value):
|
||||
if not filters.item_code or not filters.warehouse or not filters.from_date:
|
||||
return
|
||||
|
||||
if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1:
|
||||
return
|
||||
|
||||
sl_doctype = frappe.qb.DocType("Stock Ledger Entry")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sl_doctype)
|
||||
.select(
|
||||
sl_doctype.item_code,
|
||||
sl_doctype.warehouse,
|
||||
Sum(sl_doctype.actual_qty).as_("qty_after_transaction"),
|
||||
Sum(sl_doctype.stock_value_difference).as_("stock_value"),
|
||||
)
|
||||
.where(
|
||||
(sl_doctype.posting_date < filters.from_date)
|
||||
& (sl_doctype.docstatus < 2)
|
||||
& (sl_doctype.is_cancelled == 0)
|
||||
)
|
||||
)
|
||||
|
||||
if filters.get("item_code"):
|
||||
if isinstance(filters.item_code, list | tuple):
|
||||
query = query.where(sl_doctype.item_code.isin(filters.item_code))
|
||||
else:
|
||||
query = query.where(sl_doctype.item_code == filters.item_code)
|
||||
|
||||
if filters.get("warehouse"):
|
||||
if isinstance(filters.warehouse, list | tuple):
|
||||
query = query.where(sl_doctype.warehouse.isin(filters.warehouse))
|
||||
else:
|
||||
query = query.where(sl_doctype.warehouse == filters.warehouse)
|
||||
|
||||
for key, value in inv_dimension_wise_value.items():
|
||||
if isinstance(value, list | tuple):
|
||||
query = query.where(sl_doctype[key].isin(value))
|
||||
else:
|
||||
query = query.where(sl_doctype[key] == value)
|
||||
|
||||
opening_data = query.run(as_dict=True)
|
||||
|
||||
if opening_data:
|
||||
return frappe._dict(
|
||||
{
|
||||
"item_code": _("'Opening'"),
|
||||
"qty_after_transaction": opening_data[0].qty_after_transaction,
|
||||
"stock_value": opening_data[0].stock_value,
|
||||
"valuation_rate": flt(opening_data[0].stock_value)
|
||||
/ flt(opening_data[0].qty_after_transaction)
|
||||
if opening_data[0].qty_after_transaction
|
||||
else 0,
|
||||
}
|
||||
)
|
||||
|
||||
return frappe._dict({})
|
||||
|
||||
|
||||
def get_inv_dimension_wise_value(filters) -> list:
|
||||
inv_dimension_key = frappe._dict({})
|
||||
for dimension in get_inventory_dimensions():
|
||||
if dimension.fieldname in filters and filters.get(dimension.fieldname):
|
||||
return True
|
||||
inv_dimension_key[dimension.fieldname] = filters.get(dimension.fieldname)
|
||||
|
||||
return False
|
||||
if filters.get("project") and not frappe.get_all(
|
||||
"Inventory Dimension", filters={"reference_document": "Project"}
|
||||
):
|
||||
inv_dimension_key["project"] = filters.get("project")
|
||||
|
||||
return inv_dimension_key
|
||||
|
||||
@@ -21,7 +21,7 @@ frappe.query_reports["Stock Ledger Invariant Check"] = {
|
||||
options: "Item",
|
||||
get_query: function () {
|
||||
return {
|
||||
filters: { is_stock_item: 1, has_serial_no: 0 },
|
||||
filters: { is_stock_item: 1 },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -401,6 +401,9 @@ class SerialBatchBundle:
|
||||
|
||||
def submit_serial_and_batch_bundle(self):
|
||||
doc = frappe.get_doc("Serial and Batch Bundle", self.sle.serial_and_batch_bundle)
|
||||
if self.sle.voucher_detail_no and doc.voucher_detail_no != self.sle.voucher_detail_no:
|
||||
doc.voucher_detail_no = self.sle.voucher_detail_no
|
||||
|
||||
self.validate_actual_qty(doc)
|
||||
|
||||
doc.flags.ignore_voucher_validation = True
|
||||
@@ -460,6 +463,11 @@ class SerialBatchBundle:
|
||||
if sle.voucher_type in ["Sales Invoice", "Delivery Note"] and sle.actual_qty < 0:
|
||||
customer = frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "customer")
|
||||
|
||||
if sle.voucher_type in ["Stock Entry"] and sle.actual_qty < 0:
|
||||
purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose")
|
||||
if purpose in ["Disassemble", "Material Receipt"]:
|
||||
status = "Inactive"
|
||||
|
||||
sn_table = frappe.qb.DocType("Serial No")
|
||||
|
||||
query = (
|
||||
|
||||
@@ -1305,7 +1305,7 @@ class update_entries_after:
|
||||
else:
|
||||
if sle.voucher_type in ("Delivery Note", "Sales Invoice"):
|
||||
ref_doctype = "Packed Item"
|
||||
elif sle == "Subcontracting Receipt":
|
||||
elif sle.voucher_type == "Subcontracting Receipt":
|
||||
ref_doctype = "Subcontracting Receipt Supplied Item"
|
||||
else:
|
||||
ref_doctype = "Purchase Receipt Item Supplied"
|
||||
@@ -1862,6 +1862,9 @@ def get_stock_ledger_entries(
|
||||
if extra_cond:
|
||||
conditions += f"{extra_cond}"
|
||||
|
||||
if previous_sle.get("project"):
|
||||
conditions += " and project = %(project)s"
|
||||
|
||||
# nosemgrep
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
|
||||
@@ -30,6 +30,7 @@ frappe.ui.form.on("Subcontracting Receipt", {
|
||||
refresh: (frm) => {
|
||||
frappe.dynamic_link = { doc: frm.doc, fieldname: "supplier", doctype: "Supplier" };
|
||||
|
||||
erpnext.toggle_serial_batch_fields(frm);
|
||||
if (frm.doc.docstatus === 1) {
|
||||
frm.add_custom_button(
|
||||
__("Stock Ledger"),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<form action="/search_help" style="display: flex;">
|
||||
<input name='q' class='form-control' type='text'
|
||||
style='max-width: 400px; display: inline-block; margin-right: 10px;'
|
||||
value='{{ frappe.form_dict.q or ''}}'
|
||||
value='{{ (frappe.form_dict.q or '') | e }}'
|
||||
{% if not frappe.form_dict.q%}placeholder="{{ _("What do you need help with?") }}"{% endif %}>
|
||||
<input type='submit'
|
||||
class='btn btn-sm btn-light btn-search' value="{{ _("Search") }}">
|
||||
|
||||
@@ -341,6 +341,7 @@ class TransactionBase(StatusUpdater):
|
||||
args.update(
|
||||
{
|
||||
"posting_date": self.transaction_date,
|
||||
"posting_time": self.transaction_time,
|
||||
}
|
||||
)
|
||||
else:
|
||||
|
||||
102
erpnext/workspace_sidebar/organization.json
Normal file
102
erpnext/workspace_sidebar/organization.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-02-24 17:39:43.793115",
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace Sidebar",
|
||||
"header_icon": "organization",
|
||||
"idx": 1,
|
||||
"items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "organization",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Company",
|
||||
"link_to": "Company",
|
||||
"link_type": "DocType",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "book-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Letter Head",
|
||||
"link_to": "Letter Head",
|
||||
"link_type": "DocType",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "file-user",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Department",
|
||||
"link_to": "Department",
|
||||
"link_type": "DocType",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "book-user",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Branch",
|
||||
"link_to": "Branch",
|
||||
"link_type": "DocType",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "users",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "User",
|
||||
"link_to": "User",
|
||||
"link_type": "DocType",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "user-round-check",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Role Permissions",
|
||||
"link_to": "permission-manager",
|
||||
"link_type": "Page",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "mail",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Email Account",
|
||||
"link_to": "Email Account",
|
||||
"link_type": "DocType",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-24 18:08:00.796746",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"module_onboarding": "Organization Onboarding",
|
||||
"name": "Organization",
|
||||
"owner": "Administrator",
|
||||
"standard": 1,
|
||||
"title": "Organization"
|
||||
}
|
||||
Reference in New Issue
Block a user