Merge branch 'develop' into refactor-stock-gl-composer

This commit is contained in:
Nabin Hait
2026-07-11 14:28:52 +05:30
committed by GitHub
130 changed files with 83458 additions and 56415 deletions

View File

@@ -359,3 +359,13 @@ def create_accounting_dimensions_for_doctype(doctype):
create_custom_field(doctype, df, ignore_validate=True)
frappe.clear_cache(doctype=doctype)
def get_dimension_fieldname(dim_doctype: str) -> str:
"""
Return the `GL Entry` fieldname for a given dimension.
"""
if dim_doctype in ("Cost Center", "Project"):
return frappe.scrub(dim_doctype)
return frappe.db.get_value("Accounting Dimension", {"document_type": dim_doctype}, "fieldname")

View File

@@ -107,7 +107,7 @@ def get_party_bank_account(party_type, party):
)
def get_default_company_bank_account(company, party_type, party):
def get_default_company_bank_account(company, party_type, party, ignore_permissions=True):
default_company_bank_account = frappe.db.get_value(party_type, party, "default_bank_account")
if default_company_bank_account:
if company != frappe.get_cached_value("Bank Account", default_company_bank_account, "company"):
@@ -118,6 +118,14 @@ def get_default_company_bank_account(company, party_type, party):
"Bank Account", {"company": company, "is_company_account": 1, "is_default": 1}
)
if not ignore_permissions:
default_company_bank_account = (
default_company_bank_account
if default_company_bank_account
and frappe.get_cached_doc("Bank Account", default_company_bank_account).has_permission("select")
else None
)
return default_company_bank_account

View File

@@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
refresh: function (frm) {
if (frm.doc.docstatus == 1) {
frappe.call({
method: "check_journal_entry_condition",
method: "check_journal_and_reversal",
doc: frm.doc,
callback: function (r) {
if (r.message) {
frm.add_custom_button(
__("Journal Entries"),
function () {
return frm.events.make_jv(frm);
},
__("Create")
);
if (!r.message.journals_posted) {
frm.add_custom_button(
__("Journal Entries"),
function () {
return frm.events.make_jv(frm);
},
__("Create")
);
} else if (!r.message.reversals_posted) {
frm.add_custom_button(
__("Reversal Journal Entries"),
function () {
return frm.events.make_reverse_journal(frm);
},
__("Create")
);
}
}
},
});
@@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
},
});
},
make_reverse_journal: function (frm) {
frappe.call({
method: "make_reverse_journal",
doc: frm.doc,
freeze: true,
freeze_message: __("Reversing Journals..."),
});
},
});
frappe.ui.form.on("Exchange Rate Revaluation Account", {

View File

@@ -9,7 +9,7 @@ from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.query_builder import Criterion, Order
from frappe.query_builder.functions import Max, NullIf, Sum
from frappe.utils import flt, get_link_to_form
from frappe.utils import flt, get_link_to_form, nowdate
import erpnext
from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on
@@ -91,25 +91,31 @@ class ExchangeRateRevaluation(Document):
)
def on_cancel(self):
self.ignore_linked_doctypes = "GL Entry"
self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
@frappe.whitelist()
def check_journal_entry_condition(self):
def check_journal_and_reversal(self):
exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account()
journals_posted = False
reversals_posted = False
je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
journals = (
qb.from_(jea)
.select(jea.parent)
qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.name)
.distinct()
.where(
(jea.reference_type == "Exchange Rate Revaluation")
& (jea.reference_name == self.name)
& (jea.docstatus == 1)
& (je.reversal_of.isnull()) # omit journals that have reversals
)
.run()
.run(pluck="name")
)
if journals:
gle = qb.DocType("GL Entry")
total_amt = (
@@ -124,12 +130,31 @@ class ExchangeRateRevaluation(Document):
.run()
)
if total_amt and total_amt[0][0] != self.total_gain_loss:
return True
if total_amt and total_amt[0][0] == self.total_gain_loss:
journals_posted = True
else:
return False
journals_posted = False
return True
# reverse journals
reverse_journals = (
qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.name)
.where(
(jea.reference_type == "Exchange Rate Revaluation")
& (jea.reference_name == self.name)
& (jea.docstatus == 1)
& (je.reversal_of.notnull())
)
.run(pluck="name")
)
if reverse_journals:
reversals_posted = True
else:
reversals_posted = False
return {"journals_posted": journals_posted, "reversals_posted": reversals_posted}
def fetch_and_calculate_accounts_data(self):
accounts = self.get_accounts_data()
@@ -347,6 +372,7 @@ class ExchangeRateRevaluation(Document):
@frappe.whitelist()
def make_jv_entries(self):
frappe.has_permission("Journal Entry", "write", throw=True)
zero_balance_jv = self.make_jv_for_zero_balance()
if zero_balance_jv:
frappe.msgprint(
@@ -575,6 +601,38 @@ class ExchangeRateRevaluation(Document):
journal_entry.save()
return journal_entry
@frappe.whitelist()
def make_reverse_journal(self):
frappe.has_permission("Journal Entry", "write", throw=True)
je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
journals = (
qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.name)
.distinct()
.where(
(jea.reference_type == "Exchange Rate Revaluation")
& (jea.reference_name == self.name)
& (jea.docstatus == 1)
& (je.reversal_of.isnull()) # omit journals that have reversals
)
.run(pluck="name")
)
if journals:
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.submit()
frappe.msgprint(
_("Revaluation journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
)
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
"""

View File

@@ -132,7 +132,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
err = err.save().submit()
# Create JV for ERR
self.assertTrue(err.check_journal_entry_condition())
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
je = je.submit()
@@ -221,7 +222,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
err = err.save().submit()
# Create JV for ERR
self.assertTrue(err.check_journal_entry_condition())
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
je = je.submit()
@@ -299,6 +301,86 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
for key, _val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
)
def test_05_revaluation_journal_reversal(self):
"""
Test reversing of revaluation journals
"""
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=1,
)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
err = frappe.new_doc("Exchange Rate Revaluation")
err.company = self.company
err.posting_date = today()
err.fetch_and_calculate_accounts_data()
self.assertEqual(len(err.accounts), 1)
err.save().submit()
gain_loss_account = err.get_for_unrealized_gain_loss_account()
usd_account = err.accounts[0].account
old_balance = err.accounts[0].balance_in_base_currency
new_balance = err.accounts[0].new_balance_in_base_currency
total_gain_loss = err.total_gain_loss
# Create JV for ERR
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
je = je.submit()
je.reload()
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
self.assertEqual(len(je.accounts), 3)
expected = [
(usd_account, new_balance, 0.0, 100.0, 0.0),
(usd_account, 0.0, old_balance, 0.0, 100.0),
(gain_loss_account, 0.0, total_gain_loss, 0.0, total_gain_loss),
]
actual = []
for acc in je.accounts:
actual.append(
(
acc.account,
acc.debit,
acc.credit,
acc.debit_in_account_currency,
acc.credit_in_account_currency,
)
)
self.assertEqual(expected, actual)
# Assert reversals are not posted
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertFalse(ret.get("reversals_posted"))
err.make_reverse_journal()
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertTrue(ret.get("reversals_posted"))
reverse_jv = frappe.db.get_all(
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
)
self.assertIsNotNone(reverse_jv)
class TestExchangeRateRevaluationValidation(ERPNextTestSuite):
"""Validation and gain/loss calculation paths, exercised on the document directly

View File

@@ -255,16 +255,27 @@ class FinancialReportEngine:
if filters.get("presentation_currency"):
frappe.msgprint(
title=_("Unsupported Feature"),
msg=_("Currency filters are currently unsupported in Custom Financial Report."),
indicator="orange",
title=_("Not Supported"),
msg=_("Currency filters are currently unsupported in Custom Financial Report"),
)
# Margin view is dependent on first row being an income account. Hence not supported.
# Way to implement this would be using calculated rows with formulas.
supported_views = ("Report", "Growth")
if (view := filters.get("selected_view")) and view not in supported_views:
frappe.msgprint(_("{0} view is currently unsupported in Custom Financial Report.").format(view))
frappe.msgprint(
indicator="orange",
title=_("Not Supported"),
msg=_("{0} view is currently unsupported in Custom Financial Report").format(view),
)
if filters.get("group_by_dimension"):
frappe.msgprint(
indicator="orange",
title=_("Not Supported"),
msg=_("Dimension-based grouping is currently unsupported in Custom Financial Report"),
)
def _initialize_context(self, filters: dict[str, Any]) -> ReportContext:
template_name = filters.get("report_template")
@@ -1860,28 +1871,51 @@ class GrowthViewTransformer:
self.formatted_rows = context.raw_data.get("formatted_data", [])
self.period_list = context.period_list
def transform(self) -> None:
def transform(self):
for row_data in self.formatted_rows:
if row_data.get("is_blank_line"):
continue
transformed_values = {}
for i in range(len(self.period_list)):
current_period = self.period_list[i]["key"]
if row_data.get("segment_values"):
self._transform_segmented_row(row_data)
else:
self._transform_single_row(row_data)
current_value = row_data[current_period]
previous_value = row_data[self.period_list[i - 1]["key"]] if i != 0 else 0
def _compute_growth_values(self, source: dict) -> dict:
transformed = {}
if i == 0:
transformed_values[current_period] = current_value
else:
growth_percent = self._calculate_growth(previous_value, current_value)
transformed_values[current_period] = growth_percent
for i, period in enumerate(self.period_list):
current_period = period["key"]
current_value = source.get(current_period)
row_data.update(transformed_values)
if current_value in (None, ""):
continue
if i == 0:
transformed[current_period] = current_value
else:
previous_period = self.period_list[i - 1]["key"]
previous_value = source.get(previous_period) or 0
transformed[current_period] = self._calculate_growth(previous_value, current_value)
return transformed
def _transform_single_row(self, row_data: dict):
row_data.update(self._compute_growth_values(row_data))
def _transform_segmented_row(self, row_data: dict):
for seg_id, seg_data in row_data.get("segment_values", {}).items():
if seg_data.get("is_blank_line"):
continue
transformed = self._compute_growth_values(seg_data)
seg_data.update(transformed)
for period_key, value in transformed.items():
row_data[f"{seg_id}_{period_key}"] = value
def _calculate_growth(self, previous_value: float, current_value: float) -> float | None:
if current_value is None:
if current_value in (None, ""):
return None
if previous_value == 0 and current_value > 0:

View File

@@ -1,7 +1,10 @@
frappe.listview_settings["Journal Entry"] = {
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark"],
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark", "reversal_of"],
get_indicator: function (doc) {
if (doc.docstatus === 1) {
if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") {
return [__("Reversal Of Exchange Rate Revaluation"), "blue"];
}
return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`];
}
},

View File

@@ -94,11 +94,12 @@ class AssetService:
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
precision = je_row.precision("debit")
for d in depr_schedule or []:
if (
d.schedule_date == self.doc.posting_date
and not d.journal_entry
and d.depreciation_amount == flt(je_row.debit)
and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision)
):
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)

View File

@@ -82,6 +82,7 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Outstanding Amount",
"options": "Company:company:default_currency",
"reqd": 1
},
{
@@ -136,7 +137,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-04-29 17:08:15.617047",
"modified": "2026-07-02 15:17:11.938499",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",

View File

@@ -2424,6 +2424,9 @@ def get_party_details(company: str, party_type: str, party: str, date: str, cost
if not frappe.db.exists(party_type, party):
frappe.throw(_("{0} {1} does not exist").format(_(party_type), party))
ptype = "select" if frappe.only_has_select_perm(party_type) else "read"
frappe.has_permission(party_type, ptype, party, throw=True)
party_account = get_party_account(party_type, party, company)
account_currency = get_account_currency(party_account)
_party_name = "title" if party_type == "Shareholder" else party_type.lower() + "_name"
@@ -2431,7 +2434,7 @@ def get_party_details(company: str, party_type: str, party: str, date: str, cost
if party_type in ["Customer", "Supplier"]:
party_bank_account = get_party_bank_account(party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party, ignore_permissions=False)
return {
"party_account": party_account,

View File

@@ -833,10 +833,17 @@ class PaymentReconciliation(Document):
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
)
for inv in dr_cr_notes:
if (
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
< inv.allocated_amount
flt(
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
- inv.allocated_amount,
allocated_amount_precision,
)
< 0
):
frappe.throw(
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)

View File

@@ -48,6 +48,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
sinv = create_sales_invoice(
qty=qty,
rate=rate,
posting_date=posting_date,
company=self.company,
customer=self.customer,
item_code=self.item,
@@ -2110,7 +2111,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
pr.reconcile()
si.reload()
self.assertEqual(si.status, "Partly Paid")
self.assertEqual(si.status, "Overdue")
# check PR tool output post reconciliation
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120)
@@ -2506,6 +2507,76 @@ class TestPaymentReconciliation(ERPNextTestSuite):
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
pr.reconcile()
def test_cr_note_split_across_invoices_floating_point_precision(self):
"""Regression: when a credit note is split across multiple invoices, floating-point
arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail.
The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0),
so the invoices are created with round-number totals (100, 200, 100) and then partially paid
down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries.
"""
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
# Create invoices on different posting dates to control sort-order in Payment Reconciliation
# (invoices are sorted by posting_date ascending, so si_a is processed first).
# Processing order 8.45 → 90.72 → 72.57 produces the float chain:
# 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001
# The last allocation row will therefore carry allocated_amount = 50.83000000000001.
si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2))
si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1))
si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate())
# Partially pay each invoice so the remaining outstanding is a clean decimal value.
# INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings
# by subtracting a decimal-valued payment from the integer total:
# 100 - 91.55 = 8.45
# 200 - 109.28 = 90.72
# 100 - 27.43 = 72.57
for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)):
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = partial_paid
pe.received_amount = partial_paid
pe.references[0].allocated_amount = partial_paid
pe.save().submit()
cr_note = self.create_sales_invoice(
qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
cr_note.is_return = 1
cr_note = cr_note.save().submit()
pr = self.create_payment_reconciliation()
# Widen date range so all three invoices (oldest is -2 days) are fetched
pr.from_invoice_date = add_days(nowdate(), -2)
pr.to_invoice_date = nowdate()
pr.from_payment_date = nowdate()
pr.to_payment_date = nowdate()
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 3)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c
self.assertEqual(len(pr.allocation), 3)
last_row = pr.allocation[-1]
# Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic)
self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2)
# reconcile() must not raise "has been modified after you pulled it" due to float imprecision
pr.reconcile()
si_a.reload()
si_b.reload()
si_c.reload()
self.assertEqual(si_a.outstanding_amount, 0)
self.assertEqual(si_b.outstanding_amount, 0)
# si_c is only partially settled: 72.57 - 50.83 = 21.74
self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2)
def create_fiscal_year(company, year_start_date, year_end_date):
fy_docname = frappe.db.exists(

View File

@@ -542,6 +542,7 @@ class PaymentRequest(Document):
bank_amount=bank_amount,
created_from_payment_request=True,
)
payment_entry.set_missing_ref_details(force=True)
payment_entry.update(
{

View File

@@ -774,6 +774,22 @@ class TestPaymentRequest(ERPNextTestSuite):
pi.load_from_db()
self.assertEqual(pr_2.grand_total, pi.outstanding_amount)
def test_payment_entry_reference_details_fetched_from_invoice(self):
pi = make_purchase_invoice(currency="INR", qty=1, rate=94500)
pi.submit()
pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1)
pr.grand_total = 94000
pr.submit()
pe = pr.create_payment_entry(submit=False)
self.assertEqual(pe.references[0].reference_name, pi.name)
self.assertEqual(pe.references[0].total_amount, pi.grand_total)
self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount)
self.assertEqual(pe.references[0].allocated_amount, 94000)
self.assertEqual(pe.paid_amount, 94000)
def test_consider_journal_entry_and_return_invoice(self):
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry

View File

@@ -315,6 +315,77 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
repost_doc.posting_date = today()
repost_doc.save()
def test_dimension_grouped_opening_balance_matches_gl_scan(self):
"""
A dimension-grouped Balance Sheet must produce identical per-dimension
figures whether opening balances come from
- Account Closing Balance (the fast path) or
- from a full GL scan (the fallback).
"""
from frappe.utils import add_days, getdate
from erpnext.accounts.report.balance_sheet.balance_sheet import execute
from erpnext.accounts.report.financial_statements import build_period_list
company = "Test PCV Company"
cc1 = create_cost_center("Test Cost Center 1")
cc2 = create_cost_center("Test Cost Center 2")
# Post to two cost centers, then close the year so balances land in Account Closing Balance.
for amount, cost_center in ((400, cc1), (200, cc2)):
jv = make_journal_entry(
posting_date="2021-03-15",
amount=amount,
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
save=False,
)
jv.company = company
jv.save()
jv.submit()
pcv = self.make_period_closing_voucher(posting_date="2021-03-31")
report_date = add_days(getdate(pcv.period_end_date), 1)
report_filters = frappe._dict(
company=company,
period_start_date=report_date,
period_end_date=report_date,
periodicity="Yearly",
filter_based_on="Date Range",
accumulated_values=True,
group_by_dimension="Cost Center",
)
period_list = build_period_list(report_filters)
period_keys = [p.key for p in period_list]
def key_for(cost_center):
return next(p.key for p in period_list if p.dimension_value == cost_center)
def figures(data):
return {
row["account_name"]: {k: row.get(k) for k in period_keys}
for row in data
if row.get("account_name")
}
# Fast path: opening balance sourced from Account Closing Balance.
acb_figures = figures(execute(report_filters)[1])
# Fallback: force a full GL scan and expect the same numbers.
with self.change_settings("Accounts Settings", {"ignore_account_closing_balance": 1}):
gl_figures = figures(execute(report_filters)[1])
self.assertEqual(acb_figures, gl_figures)
# the fast path must carry per-dimension opening balances, not aggregates or zeros
self.assertEqual(acb_figures["Cash"][key_for(cc1)], 400)
self.assertEqual(acb_figures["Cash"][key_for(cc2)], 200)
def make_period_closing_voucher(self, posting_date, submit=True):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")

View File

@@ -156,6 +156,24 @@ class PricingRule(Document):
if len(values) != len(set(values)):
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
if self.apply_on == "Item Code":
self.validate_template_with_variant(values)
def validate_template_with_variant(self, item_codes):
# throws if a template and its variant both exist in one rule
variants = frappe.get_all(
"Item",
filters={"name": ("in", item_codes), "variant_of": ("in", item_codes)},
fields=["name", "variant_of"],
)
if variants:
variant = variants[0]
frappe.throw(
_("Variant {0} and its template {1} cannot both be added to the same Pricing Rule").format(
frappe.bold(variant.name), frappe.bold(variant.variant_of)
)
)
def validate_mandatory(self):
if self.has_priority and not self.priority:
throw(_("Priority is mandatory"), frappe.MandatoryError, _("Please Set Priority"))

View File

@@ -333,6 +333,31 @@ class TestPricingRule(ERPNextTestSuite):
details = get_item_details(args)
self.assertEqual(details.get("discount_percentage"), 17.5)
def test_pricing_rule_with_template_and_its_variant(self):
if not frappe.db.exists("Item", "Test Variant PRT"):
variant = frappe.new_doc("Item")
variant.item_code = "Test Variant PRT"
variant.item_name = "Test Variant PRT"
variant.item_group = "_Test Item Group"
variant.is_stock_item = 1
variant.variant_of = "_Test Variant Item"
variant.stock_uom = "_Test UOM"
variant.append("attributes", {"attribute": "Test Size", "attribute_value": "Medium"})
variant.insert()
rule = frappe.new_doc("Pricing Rule")
rule.title = "_Test Pricing Rule Template Variant"
rule.apply_on = "Item Code"
rule.currency = "USD"
rule.selling = 1
rule.rate_or_discount = "Discount Percentage"
rule.discount_percentage = 10
rule.company = "_Test Company"
rule.append("items", {"item_code": "_Test Variant Item"})
rule.append("items", {"item_code": "Test Variant PRT"})
self.assertRaises(frappe.ValidationError, rule.insert)
def test_pricing_rule_for_stock_qty(self):
test_record = {
"doctype": "Pricing Rule",

View File

@@ -430,6 +430,17 @@ def get_party_account(
Will first search in party (Customer / Supplier) record, if not found,
will search in group (Customer Group / Supplier Group),
finally will return default."""
def account_perm_check(account):
ptype = "select" if frappe.only_has_select_perm("Account") else "read"
if frappe.has_permission("Account", ptype, account):
return
# Using custom message to prevent data leak in case of `apply_strict_permission` is enabled.
frappe.throw(
_("User don't have permissions to select/read this account."), exc=frappe.PermissionError
)
if not party_type:
frappe.throw(_("Party Type is mandatory"))
if not company:
@@ -440,46 +451,51 @@ def get_party_account(
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
)
return frappe.get_cached_value("Company", company, default_account_name)
account = frappe.db.get_value(
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
)
if not account and party_type in ["Customer", "Supplier"]:
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
account = frappe.get_cached_value("Company", company, default_account_name)
else:
account = frappe.db.get_value(
"Party Account",
{"parenttype": party_group_doctype, "parent": group, "company": company},
"account",
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
)
if not account and party_type in ["Customer", "Supplier"]:
default_account_name = (
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
)
account = frappe.get_cached_value("Company", company, default_account_name)
if not account and party_type in ["Customer", "Supplier"]:
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
account = frappe.db.get_value(
"Party Account",
{"parenttype": party_group_doctype, "parent": group, "company": company},
"account",
)
existing_gle_currency = get_party_gle_currency(party_type, party, company)
if existing_gle_currency:
if account:
account_currency = frappe.get_cached_value("Account", account, "account_currency")
if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company)
if not account and party_type in ["Customer", "Supplier"]:
default_account_name = (
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
)
account = frappe.get_cached_value("Company", company, default_account_name)
# get default account on the basis of party type
if not account:
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
default_account_name = "default_" + account_type.lower() + "_account"
account = frappe.get_cached_value("Company", company, default_account_name)
existing_gle_currency = get_party_gle_currency(party_type, party, company)
if existing_gle_currency:
if account:
account_currency = frappe.get_cached_value("Account", account, "account_currency")
if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company)
if include_advance and party_type in ["Customer", "Supplier", "Student"]:
# get default account on the basis of party type
if not account:
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
default_account_name = "default_" + account_type.lower() + "_account"
account = frappe.get_cached_value("Company", company, default_account_name)
if account:
account_perm_check(account)
if include_advance and party and party_type in ["Customer", "Supplier", "Student"]:
advance_account = get_party_advance_account(party_type, party, company)
if advance_account:
account_perm_check(advance_account)
return [account, advance_account]
else:
return [account]
return [account]
return account

View File

@@ -264,10 +264,12 @@ class ReceivablePayableReport:
# Build and use a separate row for Employee Advances.
# This allows Payments or Journals made against Emp Advance to be processed.
if (
not row
and ple.against_voucher_type == "Employee Advance"
and self.filters.handle_employee_advances
if not row and (
(ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances)
or (
ple.against_voucher_type == "Exchange Rate Revaluation"
and self.filters.for_revaluation_journals
)
):
_d = self.build_voucher_dict(ple)
_d.voucher_type = ple.against_voucher_type

View File

@@ -8,6 +8,13 @@ frappe.query_reports[BS_REPORT_NAME] = $.extend({}, erpnext.financial_statements
erpnext.utils.add_dimensions(BS_REPORT_NAME, 10);
frappe.query_reports[BS_REPORT_NAME]["filters"].push(
{
fieldname: "group_by_dimension",
label: __("Group by Dimension"),
fieldtype: "Select",
options: erpnext.financial_statements.get_accounting_dimension_options(),
depends_on: "eval: !doc.report_template",
},
{
fieldname: "report_template",
label: __("Report Template"),

View File

@@ -13,6 +13,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
build_period_list,
calculate_values,
compute_growth_view_data,
filter_accounts,
@@ -23,7 +24,7 @@ from erpnext.accounts.report.financial_statements import (
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
get_period_keys_for_total,
prepare_data,
)
@@ -32,15 +33,10 @@ def execute(filters=None):
if filters and filters.report_template:
return FinancialReportEngine().execute(filters)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
if not period_list:
return
filters.period_start_date = period_list[0]["year_start_date"]
@@ -79,7 +75,13 @@ def execute(filters=None):
)
provisional_profit_loss, total_credit = get_provisional_profit_loss(
asset, liability, equity, period_list, filters.company, currency
asset,
liability,
equity,
period_list,
filters.company,
currency,
accumulated_values=filters.accumulated_values,
)
message, opening_balance = check_opening_balance(asset, liability, equity)
@@ -109,7 +111,11 @@ def execute(filters=None):
data.append(total_credit)
columns = get_columns(
filters.periodicity, period_list, filters.accumulated_values, company=filters.company
filters.periodicity,
period_list,
filters.accumulated_values,
company=filters.company,
selected_view=filters.get("selected_view"),
)
chart = get_chart_data(filters, period_list, asset, liability, equity, currency)
@@ -125,12 +131,18 @@ def execute(filters=None):
def get_provisional_profit_loss(
asset, liability, equity, period_list, company, currency=None, consolidated=False
asset,
liability,
equity,
period_list,
company,
currency=None,
consolidated=False,
accumulated_values=False,
):
provisional_profit_loss = {}
total_row = {}
if asset:
total = total_row_total = 0
currency = currency or frappe.get_cached_value("Company", company, "default_currency")
total_row = {
"account_name": "'" + _("Total (Credit)") + "'",
@@ -156,11 +168,9 @@ def get_provisional_profit_loss(
if provisional_profit_loss[key]:
has_value = True
total += flt(provisional_profit_loss[key])
provisional_profit_loss["total"] = total
total_row_total += flt(total_row[key])
total_row["total"] = total_row_total
total_keys = get_period_keys_for_total(period_list, accumulated_values, consolidated)
provisional_profit_loss["total"] = flt(sum(provisional_profit_loss.get(k, 0.0) for k in total_keys))
total_row["total"] = flt(sum(total_row.get(k, 0.0) for k in total_keys))
if has_value:
provisional_profit_loss.update(
@@ -204,23 +214,24 @@ def get_report_summary(
):
net_asset, net_liability, net_equity, net_provisional_profit_loss = 0.0, 0.0, 0.0, 0.0
if filters.get("accumulated_values"):
period_list = [period_list[-1]]
# from consolidated financial statement
if filters.get("accumulated_in_group_company"):
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
keys = [period if consolidated else period.key for period in period_list]
else:
keys = get_period_keys_for_total(period_list, filters.accumulated_values, consolidated)
for period in period_list:
key = period if consolidated else period.key
# get_data() output: [...account rows..., total_row, {}] → [-2] = total row, [-1] = blank separator
# [-1] == {} guards against missing total row (e.g. empty liability/equity data)
for key in keys:
if asset:
net_asset += asset[-2].get(key)
net_asset += flt(asset[-2].get(key))
if liability and liability[-1] == {}:
net_liability += liability[-2].get(key)
net_liability += flt(liability[-2].get(key))
if equity and equity[-1] == {}:
net_equity += equity[-2].get(key)
net_equity += flt(equity[-2].get(key))
if provisional_profit_loss:
net_provisional_profit_loss += provisional_profit_loss.get(key)
net_provisional_profit_loss += flt(provisional_profit_loss.get(key))
return [
{"value": net_asset, "label": _("Total Asset"), "datatype": "Currency", "currency": currency},
@@ -283,15 +294,7 @@ def execute_snapshot_report(filters):
if not (conn := get_latest_sync("GL Entry")):
frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
filters.period_start_date = period_list[0]["year_start_date"]
currency = filters.presentation_currency or frappe.get_cached_value(

View File

@@ -5,6 +5,7 @@ import frappe
from frappe.utils.data import today
from erpnext.accounts.report.balance_sheet.balance_sheet import execute
from erpnext.accounts.report.financial_statements import build_period_list, is_dimension_grouped
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company 6"
@@ -106,6 +107,79 @@ class TestBalanceSheet(ERPNextTestSuite):
self.assertIn("'Provisional Profit / Loss (Credit)'", name_and_total)
self.assertEqual(name_and_total["'Provisional Profit / Loss (Credit)'"], 100)
def test_group_by_dimension(self):
create_account("BS Dim Test Bank", f"Bank Accounts - {COMPANY_SHORT_NAME}", COMPANY)
cc1 = frappe.db.get_value("Cost Center", {"company": COMPANY, "is_group": 0}, "name")
parent_cc = frappe.db.get_value("Cost Center", {"company": COMPANY, "is_group": 1}, "name")
cc2 = frappe.new_doc("Cost Center")
cc2.cost_center_name = "BS Test CC 2"
cc2.parent_cost_center = parent_cc
cc2.company = COMPANY
cc2.insert()
make_journal_entry(
[
dict(
account_name="BS Dim Test Bank",
debit_in_account_currency=300,
credit_in_account_currency=0,
cost_center=cc1,
),
dict(
account_name="Capital Stock",
debit_in_account_currency=0,
credit_in_account_currency=300,
cost_center=cc1,
),
]
)
make_journal_entry(
[
dict(
account_name="BS Dim Test Bank",
debit_in_account_currency=500,
credit_in_account_currency=0,
cost_center=cc2.name,
),
dict(
account_name="Capital Stock",
debit_in_account_currency=0,
credit_in_account_currency=500,
cost_center=cc2.name,
),
]
)
filters = frappe._dict(
company=COMPANY,
period_start_date=today(),
period_end_date=today(),
periodicity="Yearly",
filter_based_on="Date Range",
accumulated_values=True,
group_by_dimension="Cost Center",
)
period_list = build_period_list(filters)
self.assertTrue(is_dimension_grouped(period_list))
def key_for(cost_center):
return next(p.key for p in period_list if p.dimension_value == cost_center)
columns, data, *_ = execute(filters)
# each dimension group starts with exactly one flagged column (UI boundary marker)
first_flags = [c["dimension_value"] for c in columns if c.get("is_first_in_dimension")]
self.assertEqual(len(first_flags), len(set(first_flags)))
self.assertLessEqual({cc1, cc2.name}, set(first_flags))
bank_row = next((r for r in data if r.get("account_name") == "BS Dim Test Bank"), None)
self.assertIsNotNone(bank_row)
self.assertEqual(bank_row[key_for(cc1)], 300)
self.assertEqual(bank_row[key_for(cc2.name)], 500)
self.assertEqual(bank_row["total"], 800)
def make_journal_entry(rows):
jv = frappe.new_doc("Journal Entry")

View File

@@ -17,6 +17,13 @@ erpnext.utils.add_dimensions(CF_REPORT_NAME, 10);
frappe.query_reports[CF_REPORT_NAME]["filters"].splice(8, 1);
frappe.query_reports[CF_REPORT_NAME]["filters"].push(
{
fieldname: "group_by_dimension",
label: __("Group by Dimension"),
fieldtype: "Select",
options: erpnext.financial_statements.get_accounting_dimension_options(),
depends_on: "eval: !doc.report_template",
},
{
fieldname: "report_template",
label: __("Report Template"),
@@ -42,6 +49,7 @@ frappe.query_reports[CF_REPORT_NAME]["filters"].push(
fieldname: "show_opening_and_closing_balance",
label: __("Show Opening and Closing Balance"),
fieldtype: "Check",
depends_on: "eval:!doc.group_by_dimension",
}
);

View File

@@ -10,17 +10,23 @@ from frappe.query_builder import DocType
from frappe.query_builder.functions import Sum
from frappe.utils import cstr, flt
from pypika import Order
from pypika.terms import Bracket, LiteralValue
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_dimension_with_children,
)
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FinancialReportEngine,
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
)
from erpnext.accounts.report.financial_statements import (
build_period_list,
get_columns,
get_cost_centers_with_children,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
is_dimension_grouped,
set_gl_entries_by_account,
)
from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement import (
@@ -33,15 +39,10 @@ def execute(filters=None):
if filters and filters.report_template:
return FinancialReportEngine().execute(filters)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
if not period_list:
return
cash_flow_sections = get_cash_flow_accounts()
@@ -67,7 +68,13 @@ def execute(filters=None):
ignore_accumulated_values_for_fy=True,
)
net_profit_loss = get_net_profit_loss(income, expense, period_list, filters.company)
net_profit_loss = get_net_profit_loss(
income,
expense,
period_list,
filters.company,
accumulated_values=bool(filters.accumulated_values),
)
data = []
summary_data = {}
@@ -81,6 +88,7 @@ def execute(filters=None):
"parent_section": None,
"indent": 0.0,
"section": cash_flow_section["section_header"],
"currency": company_currency,
}
)
@@ -143,8 +151,16 @@ def execute(filters=None):
add_blank_row=False,
)
if filters.show_opening_and_closing_balance:
if filters.show_opening_and_closing_balance and not is_dimension_grouped(period_list):
show_opening_and_closing_balance(data, period_list, company_currency, net_change_in_cash, filters)
elif filters.show_opening_and_closing_balance:
filters.show_opening_and_closing_balance = False
frappe.msgprint(
indicator="orange",
title=_("Not Supported"),
msg=_("Opening and Closing balance is not supported for dimension grouped cash flow statement"),
)
columns = get_columns(
filters.periodicity,
@@ -200,6 +216,8 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
filters.start_date = start_date
filters.end_date = period["to_date"]
filters.account_type = account_type
filters.dimension_field = period.get("dimension_field")
filters.dimension_value = period.get("dimension_value")
amount = get_account_type_based_gl_data(company, filters)
@@ -216,41 +234,71 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
def get_account_type_based_gl_data(company, filters=None):
filters = frappe._dict(filters or {})
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
gl = frappe.qb.DocType("GL Entry")
acc = frappe.qb.DocType("Account")
query = (
frappe.qb.from_(gle)
.select(Sum(gle.credit) - Sum(gle.debit))
frappe.qb.from_(gl)
.select(Sum(gl.credit) - Sum(gl.debit))
.where(gl.company == company)
.where(gl.posting_date >= filters.start_date)
.where(gl.posting_date <= filters.end_date)
.where(gl.voucher_type != "Period Closing Voucher")
.where(
(gle.company == company)
& (gle.posting_date >= filters.start_date)
& (gle.posting_date <= filters.end_date)
& (gle.voucher_type != "Period Closing Voucher")
& gle.account.isin(
frappe.qb.from_(account)
.select(account.name)
.where(account.account_type == filters.account_type)
gl.account.isin(
frappe.qb.from_(acc)
.select(acc.name)
.where(acc.is_group == 0)
.where(acc.company == company)
.where(acc.account_type == filters.account_type)
)
)
)
# finance book
if filters.include_default_book_entries:
company_fb = frappe.get_cached_value("Company", company, "default_finance_book")
query = query.where(
gle.finance_book.isin([filters.finance_book, company_fb, ""]) | gle.finance_book.isnull()
(gl.finance_book.isin([cstr(filters.finance_book), cstr(company_fb), ""]))
| (gl.finance_book.isnull())
)
else:
query = query.where(
gle.finance_book.isin([cstr(filters.finance_book), ""]) | gle.finance_book.isnull()
(gl.finance_book.isin([cstr(filters.finance_book), ""])) | (gl.finance_book.isnull())
)
# cost center (with children)
if filters.get("cost_center"):
cost_centers = get_cost_centers_with_children(filters.cost_center)
query = query.where(gle.cost_center.isin(cost_centers))
query = query.where(gl.cost_center.isin(cost_centers))
gl_sum = query.run()
return gl_sum[0][0] if gl_sum and gl_sum[0][0] else 0
# project
if filters.get("project"):
projects = filters.project
if not isinstance(projects, list):
projects = frappe.parse_json(projects)
query = query.where(gl.project.isin(projects))
# per-period group-by-dimension filter (always a single exact value)
if filters.get("dimension_field") and filters.get("dimension_value"):
query = query.where(gl[filters.dimension_field] == filters.dimension_value)
# accounting dimension filters selected in the filter bar
for dimension in get_accounting_dimensions(as_list=False):
if filters.get(dimension.fieldname):
values = filters[dimension.fieldname]
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
values = get_dimension_with_children(dimension.document_type, values)
query = query.where(gl[dimension.fieldname].isin(values))
# apply permission filters
from frappe.desk.reportview import build_match_conditions
if match_conditions := build_match_conditions("GL Entry"):
query = query.where(Bracket(LiteralValue(match_conditions)))
result = query.run()
return flt(result[0][0]) if result and result[0][0] else 0
def get_start_date(period, accumulated_values, company):

View File

@@ -2,9 +2,10 @@
# For license information, please see license.txt
import frappe
from frappe.utils import today
from frappe.utils import getdate, today
from erpnext.accounts.report.cash_flow.cash_flow import execute
from erpnext.accounts.report.financial_statements import build_period_list, is_dimension_grouped
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
@@ -68,3 +69,45 @@ class TestCashFlow(ERPNextTestSuite):
make_journal_entry(asset_account, "Cash - _TC", 800, posting_date=today(), submit=True)
self.assertEqual(self.net_change_in_cash() - before, -800)
def test_group_by_dimension(self):
"""Cash movements must land in their own cost center's column, not just the overall total."""
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
cc1, cc2 = "_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"
filters = frappe._dict(
company=self.company,
period_start_date=getdate(),
period_end_date=getdate(),
filter_based_on="Date Range",
periodicity="Yearly",
accumulated_values=False,
group_by_dimension="Cost Center",
)
period_list = build_period_list(filters)
self.assertTrue(is_dimension_grouped(period_list))
def key_for(cost_center):
return next(p.key for p in period_list if p.dimension_value == cost_center)
def net_change_row():
rows = execute(filters)[1]
return next((row for row in rows if row.get("section") == "'Net Change in Cash'"), {})
before = net_change_row()
# cash sales: 400 via cc1, 200 via cc2
make_journal_entry(
"Cash - _TC", "Sales - _TC", 400, cost_center=cc1, posting_date=today(), submit=True
)
make_journal_entry(
"Cash - _TC", "Sales - _TC", 200, cost_center=cc2, posting_date=today(), submit=True
)
after = net_change_row()
self.assertEqual(after.get(key_for(cc1), 0) - before.get(key_for(cc1), 0), 400)
self.assertEqual(after.get(key_for(cc2), 0) - before.get(key_for(cc2), 0), 200)
self.assertEqual(after.get("total", 0) - before.get("total", 0), 600)

View File

@@ -192,7 +192,15 @@ def get_income_expense_data(companies, fiscal_year, filters):
expense = get_data(companies, "Expense", "Debit", fiscal_year, filters, True)
net_profit_loss = get_net_profit_loss(income, expense, companies, filters.company, company_currency, True)
net_profit_loss = get_net_profit_loss(
income,
expense,
companies,
filters.company,
company_currency,
consolidated=True,
accumulated_values=bool(filters.accumulated_values),
)
return income, expense, net_profit_loss

View File

@@ -3,6 +3,7 @@
import copy
import datetime
import functools
import math
import re
@@ -15,12 +16,187 @@ from pypika.terms import Bracket, ExistsCriterion, LiteralValue
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_dimension_fieldname,
get_dimension_with_children,
get_doctypes_with_dimensions,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
from erpnext.accounts.utils import get_fiscal_year, get_zero_cutoff
def get_dimension_values(filters: frappe._dict) -> tuple[str | None, list]:
"""
Return (fieldname, [dimension_values]) for the chosen grouping dimension.
NOTE: Disabled dimensions values are not filtered out!
"""
if not filters.group_by_dimension:
return None, []
dim_doctype = filters.group_by_dimension
fieldname = get_dimension_fieldname(dim_doctype)
meta = frappe.get_meta(dim_doctype)
is_tree = bool(meta.is_tree)
dim = frappe.qb.DocType(dim_doctype)
query = frappe.qb.from_(dim).select(dim.name)
if is_tree and meta.has_field("is_group"):
query = query.where(dim.is_group == 0)
if meta.has_field("company"):
query = query.where(dim.company == filters.company)
# Self-filter: narrow to values the user picked for this same dimension.
if selected := filters.get(fieldname):
if isinstance(selected, str):
selected = frappe.parse_json(selected)
if is_tree:
selected = get_dimension_with_children(dim_doctype, selected)
query = query.where(dim.name.isin(selected))
from frappe.desk.reportview import build_match_conditions
if match_conditions := build_match_conditions(dim_doctype):
query = query.where(Bracket(LiteralValue(match_conditions)))
# order by name
query = query.orderby(dim.name)
return fieldname, query.run(pluck=True)
def get_dimension_period_list(filters: frappe._dict) -> list[dict]:
"""
Return a period_list-shaped axis = cross-product of (dimension_value * time period).
Each cell is a `get_period_list` bucket plus dimension keys, e.g.:
```
{
"dimension_field": "cost_center",
"dimension_value": "Main - ATD",
"key": "main___atd_mar_2027",
"label": "Main - ATD - 2026-2027",
"period": "mar_2027",
...
}
```
"""
fieldname, dimensions = get_dimension_values(filters)
if not fieldname or not dimensions:
return []
period_buckets = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
accumulated_values=filters.accumulated_values,
company=filters.company,
)
if not period_buckets:
return []
period_list = []
# Guard against rare collisions where two distinct dimension values
# `frappe.scrub()` to the same key (e.g. "CC-A" and "CC A") and would
# otherwise overwrite each other's column.
used_keys = set()
for dimension in dimensions:
dim_key_base = frappe.scrub(dimension)
for period in period_buckets:
key = f"{dim_key_base}_{period.key}"
if key in used_keys:
key = f"{key}_{len(used_keys)}"
used_keys.add(key)
cell = frappe._dict(period)
cell.update(
{
"key": key,
"label": f"{dimension} - {period.label}",
"dimension_field": fieldname,
"dimension_value": dimension,
"period": period.key,
}
)
period_list.append(cell)
return period_list
def build_period_list(filters: frappe._dict) -> list[dict]:
"""
Build the report `period_list` from filters.
- If `group_by_dimension` is set, returns a dimension * period cross-product via `get_dimension_period_list`.
- Otherwise, returns plain time buckets via `get_period_list`.
"""
if filters.group_by_dimension and not filters.report_template:
return get_dimension_period_list(filters)
return get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
def is_dimension_grouped(period_list: list[dict]) -> bool:
"""
Return True if period_list contains dimension-grouped periods.
"""
if not period_list or not isinstance(period_list, list):
return False
return bool(period_list[0].get("dimension_field"))
def get_period_keys_for_total(
period_list: list[dict],
accumulated_values: bool,
consolidated: bool = False,
) -> list[str]:
"""
Return the period keys whose values should be summed for the row-level
`Total` column / report-summary cards.
- Group by Dimension + accumulated: each dimension's last period
- Accumulated: only the last period
- Not accumulated: all periods (sum of independent period activity)
- Consolidated: list of period keys is the same as the period_list
- In case of consolidated reports
"""
if not period_list:
return []
if consolidated:
return list(period_list)
if is_dimension_grouped(period_list) and accumulated_values:
return list({period.dimension_value: period.key for period in period_list}.values())
# when 'accumulated_values' is enabled, periods have running balance.
# so, last period will have the net amount.
if accumulated_values:
return [period_list[-1].key]
return [period.key for period in period_list]
def get_period_list(
from_fiscal_year,
to_fiscal_year,
@@ -33,18 +209,19 @@ def get_period_list(
reset_period_on_fy_change=True,
ignore_fiscal_year=False,
):
"""Get a list of dict {"from_date": from_date, "to_date": to_date, "key": key, "label": label}
Periodicity can be (Yearly, Quarterly, Monthly)"""
"""
Generate a list of time buckets between the provided from/to fiscal year or date range,
based on the periodicity (Yearly, Half-Yearly, Quarterly, Monthly).
"""
# Resolve the report's overall date range (with validation).
if filter_based_on == "Fiscal Year":
fiscal_year = get_fiscal_year_data(from_fiscal_year, to_fiscal_year)
validate_fiscal_year(fiscal_year, from_fiscal_year, to_fiscal_year)
year_start_date = getdate(fiscal_year.year_start_date)
year_end_date = getdate(fiscal_year.year_end_date)
fy_data = get_fiscal_year_data(from_fiscal_year, to_fiscal_year)
validate_fiscal_year(fy_data, from_fiscal_year, to_fiscal_year)
year_start_date, year_end_date = getdate(fy_data.year_start_date), getdate(fy_data.year_end_date)
else:
validate_dates(period_start_date, period_end_date)
year_start_date = getdate(period_start_date)
year_end_date = getdate(period_end_date)
year_start_date, year_end_date = getdate(period_start_date), getdate(period_end_date)
months_to_add = {"Yearly": 12, "Half-Yearly": 6, "Quarterly": 3, "Monthly": 1}[periodicity]
@@ -233,6 +410,8 @@ def calculate_values(
accumulated_values,
ignore_accumulated_values_for_fy,
):
grouped_by_dimension = is_dimension_grouped(period_list)
for entries in gl_entries_by_account.values():
for entry in entries:
d = accounts_by_name.get(entry.account)
@@ -243,7 +422,8 @@ def calculate_values(
raise_exception=1,
)
for period in period_list:
# check if posting date is within the period
if grouped_by_dimension and entry.get(period.dimension_field) != period.dimension_value:
continue
if entry.posting_date <= period.to_date:
if (accumulated_values or entry.posting_date >= period.from_date) and (
@@ -252,7 +432,8 @@ def calculate_values(
):
d[period.key] = d.get(period.key, 0.0) + flt(entry.debit) - flt(entry.credit)
if entry.posting_date < period_list[0].year_start_date:
# Balance Sheet only: track pre-FY entries as opening_balance (no per-dimension breakdown possible).
if not grouped_by_dimension and entry.posting_date < period_list[0].year_start_date:
d["opening_balance"] = d.get("opening_balance", 0.0) + flt(entry.debit) - flt(entry.credit)
@@ -274,11 +455,11 @@ def prepare_data(accounts, balance_must_be, period_list, company_currency, accum
data = []
year_start_date = period_list[0]["year_start_date"].strftime("%Y-%m-%d")
year_end_date = period_list[-1]["year_end_date"].strftime("%Y-%m-%d")
total_keys = get_period_keys_for_total(period_list, accumulated_values)
for d in accounts:
# add to output
has_value = False
total = 0
row = frappe._dict(
{
"account": _(d.name),
@@ -303,21 +484,14 @@ def prepare_data(accounts, balance_must_be, period_list, company_currency, accum
# change sign based on Debit or Credit, since calculation is done using (debit - credit)
d[period.key] *= -1
row[period.key] = flt(d.get(period.key, 0.0), 3)
row[period.key] = flt(d.get(period.key, 0), 3)
if abs(row[period.key]) >= get_zero_cutoff(company_currency):
# ignore zero values
has_value = True
total += flt(row[period.key])
if accumulated_values:
# when 'accumulated_values' is enabled, periods have running balance.
# so, last period will have the net amount.
row["has_value"] = has_value
row["total"] = flt(d.get(period_list[-1].key, 0.0), 3)
else:
row["has_value"] = has_value
row["total"] = total
row["has_value"] = has_value
row["total"] = flt(sum(row.get(k, 0) for k in total_keys), 3)
data.append(row)
return data
@@ -547,6 +721,10 @@ def get_accounting_entries(
.where(gl_entry.company == filters.company)
)
if filters.group_by_dimension and doctype in get_doctypes_with_dimensions() and not group_by_account:
dimension_field = get_dimension_fieldname(filters.group_by_dimension)
query = query.select(gl_entry[dimension_field])
if not ignore_reporting_currency:
query = query.select(
gl_entry.debit_in_reporting_currency
@@ -687,7 +865,14 @@ def get_cost_centers_with_children(cost_centers):
return list(set(all_cost_centers))
def get_columns(periodicity, period_list, accumulated_values=1, company=None, cash_flow=False):
def get_columns(
periodicity,
period_list,
accumulated_values=1,
company=None,
cash_flow=False,
selected_view="Report",
):
columns = [
{
"fieldname": "account" if not cash_flow else "section",
@@ -697,6 +882,7 @@ def get_columns(periodicity, period_list, accumulated_values=1, company=None, ca
"width": 300,
}
]
if not cash_flow:
columns.extend(
[
@@ -716,6 +902,7 @@ def get_columns(periodicity, period_list, accumulated_values=1, company=None, ca
},
]
)
if company:
columns.append(
{
@@ -726,27 +913,40 @@ def get_columns(periodicity, period_list, accumulated_values=1, company=None, ca
"hidden": 1,
}
)
seen_dim_values = set()
for period in period_list:
col = {
"fieldname": period.key,
"label": period.label,
"fieldtype": "Currency",
"options": "currency",
"width": 150,
}
if dim_value := period.get("dimension_value"):
# used to identify cross-dimension boundaries
col["dimension_value"] = dim_value
# to handle special view (Growth/Margin) formatting in UI.
if dim_value not in seen_dim_values:
seen_dim_values.add(dim_value)
col["is_first_in_dimension"] = True
columns.append(col)
if selected_view not in ("Growth", "Margin") and (
is_dimension_grouped(period_list) or (periodicity != "Yearly" and not accumulated_values)
):
columns.append(
{
"fieldname": period.key,
"label": period.label,
"fieldname": "total",
"label": _("Total"),
"fieldtype": "Currency",
"options": "currency",
"width": 150,
"options": "currency",
}
)
if periodicity != "Yearly":
if not accumulated_values:
columns.append(
{
"fieldname": "total",
"label": _("Total"),
"fieldtype": "Currency",
"width": 150,
"options": "currency",
}
)
return columns
@@ -768,6 +968,10 @@ def compute_growth_view_data(data, columns):
continue
for column_idx in range(1, len(columns)):
# No growth comparison across dimension boundaries
if columns[column_idx - 1].get("dimension_value") != columns[column_idx].get("dimension_value"):
continue
previous_period_key = columns[column_idx - 1].get("key")
current_period_key = columns[column_idx].get("key")
current_period_value = data_copy[row_idx].get(current_period_key)
@@ -789,13 +993,10 @@ def compute_growth_view_data(data, columns):
data[row_idx][current_period_key] = growth_percent
def compute_margin_view_data(data, columns, accumulated_values):
def compute_margin_view_data(data, columns):
if not columns:
return
if not accumulated_values:
columns.append({"key": "total"})
data_copy = copy.deepcopy(data)
base_row = None

View File

@@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
)
if total_base_amount
else 0,
"currency": filters.currency,
}
)
)
@@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
"buying_amount": total_buying_amount,
"gross_profit": total_gross_profit,
"gross_profit_percent": flt(gross_profit_percent, currency_precision),
"currency": filters.currency,
}
total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]]

View File

@@ -8,6 +8,13 @@ frappe.query_reports[PL_REPORT_NAME] = $.extend({}, erpnext.financial_statements
erpnext.utils.add_dimensions(PL_REPORT_NAME, 10);
frappe.query_reports[PL_REPORT_NAME]["filters"].push(
{
fieldname: "group_by_dimension",
label: __("Group by Dimension"),
fieldtype: "Select",
options: erpnext.financial_statements.get_accounting_dimension_options(),
depends_on: "eval: !doc.report_template",
},
{
fieldname: "report_template",
label: __("Report Template"),

View File

@@ -13,6 +13,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
build_period_list,
calculate_values,
compute_growth_view_data,
compute_margin_view_data,
@@ -23,7 +24,7 @@ from erpnext.accounts.report.financial_statements import (
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
get_period_keys_for_total,
prepare_data,
)
@@ -32,15 +33,10 @@ def execute(filters=None):
if filters and filters.report_template:
return FinancialReportEngine().execute(filters)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
if not period_list:
return
income = get_data(
filters.company,
@@ -63,7 +59,12 @@ def execute(filters=None):
)
net_profit_loss = get_net_profit_loss(
income, expense, period_list, filters.company, filters.presentation_currency
income,
expense,
period_list,
filters.company,
filters.presentation_currency,
accumulated_values=bool(filters.accumulated_values),
)
data = []
@@ -72,7 +73,13 @@ def execute(filters=None):
if net_profit_loss:
data.append(net_profit_loss)
columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company)
columns = get_columns(
filters.periodicity,
period_list,
filters.accumulated_values,
filters.company,
selected_view=filters.get("selected_view"),
)
currency = filters.presentation_currency or frappe.get_cached_value(
"Company", filters.company, "default_currency"
@@ -87,39 +94,38 @@ def execute(filters=None):
compute_growth_view_data(data, period_list)
if filters.get("selected_view") == "Margin":
compute_margin_view_data(data, period_list, filters.accumulated_values)
compute_margin_view_data(data, period_list)
return columns, data, None, chart, report_summary, primitive_summary
def get_report_summary(
period_list, periodicity, income, expense, net_profit_loss, currency, filters, consolidated=False
period_list,
periodicity,
income,
expense,
net_profit_loss,
currency,
filters,
consolidated=False,
):
net_income, net_expense, net_profit = 0.0, 0.0, 0.0
# from consolidated financial statement
if filters.get("accumulated_in_group_company"):
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
if filters.accumulated_values:
# when 'accumulated_values' is enabled, periods have running balance.
# so, last period will have the net amount.
key = period_list[-1].key
if income:
net_income = income[-2].get(key)
if expense:
net_expense = expense[-2].get(key)
if net_profit_loss:
net_profit = net_profit_loss.get(key)
keys = [period if consolidated else period.key for period in period_list]
else:
for period in period_list:
key = period if consolidated else period.key
if income:
net_income += income[-2].get(key)
if expense:
net_expense += expense[-2].get(key)
if net_profit_loss:
net_profit += net_profit_loss.get(key)
keys = get_period_keys_for_total(period_list, filters.accumulated_values, consolidated)
# get_data() output: [...account rows..., total_row, {}] → [-2] = total row, [-1] = blank separator
for key in keys:
if income:
net_income += flt(income[-2].get(key))
if expense:
net_expense += flt(expense[-2].get(key))
if net_profit_loss:
net_profit += flt(net_profit_loss.get(key))
if len(period_list) == 1 and periodicity == "Yearly":
profit_label = _("Profit This Year")
@@ -143,8 +149,15 @@ def get_report_summary(
], net_profit
def get_net_profit_loss(income, expense, period_list, company, currency=None, consolidated=False):
total = 0
def get_net_profit_loss(
income,
expense,
period_list,
company,
currency=None,
consolidated=False,
accumulated_values=False,
):
net_profit_loss = {
"account_name": "'" + _("Profit for the year") + "'",
"account": "'" + _("Profit for the year") + "'",
@@ -164,8 +177,9 @@ def get_net_profit_loss(income, expense, period_list, company, currency=None, co
if net_profit_loss[key]:
has_value = True
total += flt(net_profit_loss[key])
net_profit_loss["total"] = total
total_keys = get_period_keys_for_total(period_list, accumulated_values, consolidated)
net_profit_loss["total"] = flt(sum(net_profit_loss.get(k, 0.0) for k in total_keys))
if has_value:
return net_profit_loss
@@ -215,15 +229,7 @@ def execute_snapshot_report(filters):
_("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))
)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list)
expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list)

View File

@@ -6,7 +6,11 @@ from frappe.desk.query_report import export_query
from frappe.utils import add_days, getdate, today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.financial_statements import get_period_list
from erpnext.accounts.report.financial_statements import (
build_period_list,
get_period_list,
is_dimension_grouped,
)
from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
@@ -60,6 +64,75 @@ class TestProfitAndLossStatement(ERPNextTestSuite, AccountsTestMixin):
accumulated_values=False,
)
def _create_cost_center(self, name):
parent = frappe.db.get_value("Cost Center", self.cost_center, "parent_cost_center")
cc = frappe.new_doc("Cost Center")
cc.cost_center_name = name
cc.parent_cost_center = parent
cc.company = self.company
cc.insert()
return cc.name
def test_group_by_dimension(self):
second_cc = self._create_cost_center("P&L Test CC 2")
# 100 to default cost center, 200 to second cost center
self.create_sales_invoice(rate=100)
si2 = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=second_cc,
cost_center=second_cc,
rate=200,
price_list_rate=200,
qty=1,
)
si2.submit()
filters = self.get_report_filters()
filters.group_by_dimension = "Cost Center"
period_list = build_period_list(filters)
self.assertTrue(is_dimension_grouped(period_list))
posting_date = getdate()
def key_for(cost_center):
return next(
p.key
for p in period_list
if p.dimension_value == cost_center and p.from_date <= posting_date <= p.to_date
)
columns, data, *_ = execute(filters)
self.assertLessEqual({self.cost_center, second_cc}, {c.get("dimension_value") for c in columns})
income_account = frappe.db.get_value("Company", self.company, "default_income_account")
income_row = next((r for r in data if r.get("account") == income_account), None)
self.assertIsNotNone(income_row)
cc1_key, cc2_key = key_for(self.cost_center), key_for(second_cc)
self.assertEqual(income_row[cc1_key], 100)
self.assertEqual(income_row[cc2_key], 200)
# no leakage into other dimension or period columns
for period in period_list:
if period.key not in (cc1_key, cc2_key):
self.assertEqual(income_row[period.key], 0)
# non-accumulated: total = sum of all dimension-period values
self.assertEqual(income_row["total"], 300.0)
# accumulated: total must take each dimension's last running balance once,
# not sum every accumulated column
filters.accumulated_values = True
data = execute(filters)[1]
income_row = next(r for r in data if r.get("account") == income_account)
self.assertEqual(income_row["total"], 300.0)
def test_profit_and_loss_output_and_summary(self):
self.create_sales_invoice(qty=1, rate=150)

View File

@@ -0,0 +1,57 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Deferred revenue/expense accounting validations."""
import frappe
from frappe import _
from frappe.utils import getdate
DEFERRED_ACCOUNT_FIELD = {
"Sales Invoice": "deferred_revenue_account",
"Purchase Invoice": "deferred_expense_account",
}
class DeferredAccountingService:
def __init__(self, doc):
self.doc = doc
def validate_income_expense_account(self) -> None:
account_field = DEFERRED_ACCOUNT_FIELD.get(self.doc.doctype)
for item in self.doc.get("items"):
if not self._is_deferred(item) or item.get(account_field):
continue
default_account = frappe.get_cached_value("Company", self.doc.company, "default_" + account_field)
if not default_account:
frappe.throw(
_(
"Row #{0}: Please update deferred revenue/expense account in item row or default account in company master"
).format(item.idx)
)
item.set(account_field, default_account)
def validate_start_and_end_date(self) -> None:
for item in self.doc.items:
if not self._is_deferred(item):
continue
if not (item.service_start_date and item.service_end_date):
frappe.throw(
_("Row #{0}: Service Start and End Date is required for deferred accounting").format(
item.idx
)
)
elif getdate(item.service_start_date) > getdate(item.service_end_date):
frappe.throw(
_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(item.idx)
)
elif getdate(self.doc.posting_date) > getdate(item.service_end_date):
frappe.throw(
_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(item.idx)
)
def _is_deferred(self, item) -> bool:
return bool(item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"))

View File

@@ -293,6 +293,39 @@ class PaymentScheduleService:
_("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total")
)
def validate_all_documents_schedule(self) -> None:
if self.doc.doctype in ("Sales Invoice", "Purchase Invoice"):
self.validate_invoice_documents_schedule()
elif self.doc.doctype in ("Quotation", "Purchase Order", "Sales Order"):
self.validate_non_invoice_documents_schedule()
def validate_invoice_documents_schedule(self) -> None:
doc = self.doc
if (
doc.is_return
or (doc.doctype == "Purchase Invoice" and doc.is_paid)
or (doc.doctype == "Sales Invoice" and doc.is_pos)
or doc.get("is_opening") == "Yes"
):
doc.payment_terms_template = ""
doc.payment_schedule = []
if doc.is_return:
return
self.validate_payment_schedule_dates()
self.set_due_date()
self.set_payment_schedule()
if not doc.get("ignore_default_payment_terms_template"):
self.validate_payment_schedule_amount()
doc.validate_due_date()
doc.validate_advance_entries()
def validate_non_invoice_documents_schedule(self) -> None:
self.set_payment_schedule()
self.validate_payment_schedule_dates()
self.validate_payment_schedule_amount()
def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None:
return frappe.get_value(doctype, po_or_so, "payment_terms_template")

View File

@@ -1478,6 +1478,46 @@ class TestDepreciationBasics(AssetSetup):
self.assertFalse(depr_schedule[1].journal_entry)
self.assertFalse(depr_schedule[2].journal_entry)
def test_depr_schedule_link_matches_at_currency_precision(self):
"""A Depreciation Schedule row whose amount carries more decimals than the
company currency (e.g. 25701.202 vs a JE debit of 25701.20) must still be
matched and stamped with the Journal Entry. Comparing at exact float
equality left the link NULL, so the scheduler treated the row as unposted
and created a duplicate Journal Entry on every run. Regression test for
AssetService.update_journal_entry_link_on_depr_schedule()."""
from unittest.mock import MagicMock, patch
from erpnext.accounts.doctype.journal_entry.services import asset_service as asset_service_module
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
posting_date = getdate("2021-06-01")
je = frappe._dict(name="JE-DEPR-TEST", finance_book=None, posting_date=posting_date)
service = AssetService(je)
# JE debit is stored at company currency precision (2 dp)...
je_row = MagicMock()
je_row.debit = 25701.20
je_row.precision.return_value = 2
# ...while the schedule row amount carries a third decimal.
schedule_row = frappe._dict(
name="DS-ROW-1",
schedule_date=posting_date,
journal_entry=None,
depreciation_amount=25701.202,
)
asset = frappe._dict(name="ASSET-TEST")
with (
patch.object(asset_service_module, "get_depr_schedule", return_value=[schedule_row]),
patch.object(frappe.db, "set_value") as mock_set_value,
):
service.update_journal_entry_link_on_depr_schedule(asset, je_row)
mock_set_value.assert_called_once_with(
"Depreciation Schedule", "DS-ROW-1", "journal_entry", "JE-DEPR-TEST"
)
def test_depr_entry_posting_when_depr_expense_account_is_an_expense_account(self):
"""Tests if the Depreciation Expense Account gets debited and the Accumulated Depreciation Account gets credited when the former's an Expense Account."""

View File

@@ -14,7 +14,6 @@ def execute(filters=None):
conditions = get_columns(filters, "Purchase Order")
data = get_data(filters, conditions)
chart_data = get_chart_data(data, conditions, filters)
return conditions["columns"], data, None, chart_data
@@ -39,9 +38,15 @@ def get_chart_data(data, conditions, filters):
labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns]
datapoints = [0] * len(labels)
group_by_col_idx = None
if filters.get("group_by"):
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
for row in data:
# If group by filter, don't add first row of group (it's already summed)
if not row[start]:
# Skip the final grand-total row
if row[0] == f"'{_('Total')}'":
continue
if group_by_col_idx is not None and row[group_by_col_idx] == "":
continue
# Remove None values and compute only periodic data
row = [x if x else 0 for x in row[start:-2]]
@@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters):
"type": "line",
"lineOptions": {"regionFill": 1},
"fieldtype": "Currency",
"options": "currency",
"currency": conditions.get("company_currency"),
}

View File

@@ -2,7 +2,10 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import today
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
@@ -30,3 +33,166 @@ class TestPurchaseOrderTrends(ERPNextTestSuite):
self.assertTrue(columns)
supplier_rows = [row for row in data if row[0] == "_Test Supplier"]
self.assertEqual(len(supplier_rows), 1)
def test_total_row_not_double_counted_in_chart(self):
# Regression test for the fix in trends.calculate_total_row that populates the
# Total row's Currency column. Before the fix in get_chart_data (skipping the
# Total row by label instead of `if not row[start]`), that populated Currency
# cell made the Total-row-skip guard falsy, so the already-summed Total row got
# added into the chart a second time (a PO of qty=3, rate=100 -> 300 read as 600).
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute
create_purchase_order(supplier="_Test Supplier", qty=3, rate=100, transaction_date=today())
fiscal_year = get_fiscal_year(today())[0]
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": fiscal_year,
"period": "Monthly",
"based_on": "Item",
}
)
columns, data, _message, chart = execute(filters)
self.assertTrue(columns)
self.assertTrue(data)
# The Total row (present in `data`) must not be re-summed into the chart's datapoints.
total_row = next(row for row in data if row[0] == f"'{_('Total')}'")
expected_total = total_row[-1] # Total(Amt) is the last column
chart_total = sum(chart["data"]["datasets"][0]["values"])
self.assertEqual(chart_total, expected_total)
self.assertEqual(chart_total, 300)
def test_chart_currency_matches_company_currency(self):
# Regression test: the chart's "currency" key should reflect the transacting
# company's currency (conditions["company_currency"]), not a stale global default.
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute
create_purchase_order(supplier="_Test Supplier", qty=1, rate=100, transaction_date=today())
fiscal_year = get_fiscal_year(today())[0]
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": fiscal_year,
"period": "Monthly",
"based_on": "Item",
}
)
_columns, _data, _message, chart = execute(filters)
expected_currency = frappe.get_cached_value("Company", "_Test Company", "default_currency")
self.assertEqual(chart["currency"], expected_currency)
def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self):
# _Test Item is split across two suppliers -> two detail rows under one header row.
# _Test Item 2 has only one supplier -> exactly one detail row under its header row.
# A regression that double-counts header rows would inflate the chart above 600;
# a regression that zeroes single-group rows would report less than 600.
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute
create_purchase_order(
item_code="_Test Item", supplier="_Test Supplier", qty=3, rate=100, transaction_date=today()
)
create_purchase_order(
item_code="_Test Item", supplier="_Test Supplier 1", qty=2, rate=100, transaction_date=today()
)
create_purchase_order(
item_code="_Test Item 2", supplier="_Test Supplier", qty=1, rate=100, transaction_date=today()
)
fiscal_year = get_fiscal_year(today())[0]
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": fiscal_year,
"period": "Monthly",
"based_on": "Item",
"group_by": "Supplier",
}
)
columns, data, _message, chart = execute(filters)
self.assertTrue(columns)
self.assertTrue(data)
total_row = next(row for row in data if row[0] == f"'{_('Total')}'")
expected_total = total_row[-1]
chart_total = sum(chart["data"]["datasets"][0]["values"])
# 300 (item/supplier) + 200 (item/supplier1) + 100 (item2/supplier) = 600
self.assertEqual(expected_total, 600)
self.assertEqual(chart_total, expected_total)
def test_group_by_swapped_roles_based_on_supplier_group_by_item(self):
# Same regression, opposite role assignment: based_on="Supplier" with group_by="Item".
# Supplier's based_on_cols (Supplier, Supplier Name, Supplier Group, Currency) put the
# group_by placeholder at a different column index than the Item-based_on case above,
# exercising the alternate `inc`/`ind` arithmetic.
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute
create_purchase_order(
item_code="_Test Item", supplier="_Test Supplier", qty=3, rate=100, transaction_date=today()
)
create_purchase_order(
item_code="_Test Item 2", supplier="_Test Supplier", qty=1, rate=100, transaction_date=today()
)
fiscal_year = get_fiscal_year(today())[0]
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": fiscal_year,
"period": "Monthly",
"based_on": "Supplier",
"group_by": "Item",
}
)
columns, data, _message, chart = execute(filters)
total_row = next(row for row in data if row[0] == f"'{_('Total')}'")
expected_total = total_row[-1]
chart_total = sum(chart["data"]["datasets"][0]["values"])
# 300 + 100 = 400
self.assertEqual(expected_total, 400)
self.assertEqual(chart_total, expected_total)
def test_group_by_single_group_value_not_zeroed(self):
# Isolates the specific failure mode flagged in review: a based_on value with exactly
# one associated group value must still contribute its real amount to the chart, not 0.
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute
create_purchase_order(
item_code="_Test Item", supplier="_Test Supplier", qty=2, rate=150, transaction_date=today()
)
fiscal_year = get_fiscal_year(today())[0]
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": fiscal_year,
"period": "Monthly",
"based_on": "Item",
"group_by": "Supplier",
}
)
columns, data, _message, chart = execute(filters)
chart_total = sum(chart["data"]["datasets"][0]["values"])
self.assertGreater(chart_total, 0)
self.assertEqual(chart_total, 300)

View File

@@ -234,7 +234,9 @@ class AccountsController(TransactionBase):
if self.is_return:
self.validate_qty()
else:
self.validate_deferred_start_and_end_date()
from erpnext.accounts.services.deferred_accounting import DeferredAccountingService
DeferredAccountingService(self).validate_start_and_end_date()
from erpnext.accounts.services.internal_transfer import InternalTransferService
@@ -262,7 +264,9 @@ class AccountsController(TransactionBase):
validate_return(self)
self.validate_all_documents_schedule()
from erpnext.accounts.services.payment_schedule import PaymentScheduleService
PaymentScheduleService(self).validate_all_documents_schedule()
from erpnext.accounts.services.party_validation import PartyValidator
@@ -286,7 +290,9 @@ class AccountsController(TransactionBase):
self.set_advance_gain_or_loss()
self.validate_deferred_income_expense_account()
from erpnext.accounts.services.deferred_accounting import DeferredAccountingService
DeferredAccountingService(self).validate_income_expense_account()
InternalTransferService(self).set_account()
if self.doctype == "Purchase Invoice":
@@ -504,89 +510,10 @@ class AccountsController(TransactionBase):
)
)
def validate_deferred_income_expense_account(self):
field_map = {
"Sales Invoice": "deferred_revenue_account",
"Purchase Invoice": "deferred_expense_account",
}
for item in self.get("items"):
if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"):
if not item.get(field_map.get(self.doctype)):
default_deferred_account = frappe.get_cached_value(
"Company", self.company, "default_" + field_map.get(self.doctype)
)
if not default_deferred_account:
frappe.throw(
_(
"Row #{0}: Please update deferred revenue/expense account in item row or default account in company master"
).format(item.idx)
)
else:
item.set(field_map.get(self.doctype), default_deferred_account)
def validate_auto_repeat_subscription_dates(self):
if self.get("from_date") and self.get("to_date") and getdate(self.from_date) > getdate(self.to_date):
frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date"))
def validate_deferred_start_and_end_date(self):
for d in self.items:
if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"):
if not (d.service_start_date and d.service_end_date):
frappe.throw(
_("Row #{0}: Service Start and End Date is required for deferred accounting").format(
d.idx
)
)
elif getdate(d.service_start_date) > getdate(d.service_end_date):
frappe.throw(
_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(
d.idx
)
)
elif getdate(self.posting_date) > getdate(d.service_end_date):
frappe.throw(
_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx)
)
def validate_invoice_documents_schedule(self):
if (
self.is_return
or (self.doctype == "Purchase Invoice" and self.is_paid)
or (self.doctype == "Sales Invoice" and self.is_pos)
or self.get("is_opening") == "Yes"
):
self.payment_terms_template = ""
self.payment_schedule = []
if self.is_return:
return
from erpnext.accounts.services.payment_schedule import PaymentScheduleService
ps = PaymentScheduleService(self)
ps.validate_payment_schedule_dates()
ps.set_due_date()
ps.set_payment_schedule()
if not self.get("ignore_default_payment_terms_template"):
ps.validate_payment_schedule_amount()
self.validate_due_date()
self.validate_advance_entries()
def validate_non_invoice_documents_schedule(self):
from erpnext.accounts.services.payment_schedule import PaymentScheduleService
ps = PaymentScheduleService(self)
ps.set_payment_schedule()
ps.validate_payment_schedule_dates()
ps.validate_payment_schedule_amount()
def validate_all_documents_schedule(self):
if self.doctype in ("Sales Invoice", "Purchase Invoice"):
self.validate_invoice_documents_schedule()
elif self.doctype in ("Quotation", "Purchase Order", "Sales Order"):
self.validate_non_invoice_documents_schedule()
def before_print(self, settings=None):
if self.doctype in [
"Purchase Order",

View File

@@ -186,6 +186,68 @@ def update_variant_attribute_values(item_attribute):
frappe.flags.attribute_values = None
def get_attribute_abbr_renames(item_attribute):
"""Return the set of (current) attribute values whose abbreviation was renamed."""
if item_attribute.numeric_values:
return set()
db_value = item_attribute.get_doc_before_save()
if not db_value:
return set()
old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values}
changed_values = set()
for row in item_attribute.item_attribute_values:
if row.name in old_abbrs and old_abbrs[row.name] != row.abbr:
changed_values.add(row.attribute_value)
return changed_values
def update_variant_item_codes_for_abbr_renames(item_attribute):
"""Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation."""
changed_values = get_attribute_abbr_renames(item_attribute)
if not changed_values:
return
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
variant_names = (
frappe.qb.from_(item_variant_table)
.select(item_variant_table.parent)
.where(item_variant_table.attribute == item_attribute.name)
.where(item_variant_table.attribute_value.isin(list(changed_values)))
.distinct()
.run(pluck=True)
)
for variant_name in variant_names:
rename_variant_item_code(variant_name)
def rename_variant_item_code(variant_name):
"""Recompute a variant's item_code/item_name from its template and current attribute abbreviations,
renaming the Item if it has changed."""
variant = frappe.get_doc("Item", variant_name)
if not variant.variant_of:
return
template = frappe.get_cached_doc("Item", variant.variant_of)
new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes})
make_variant_item_code(template.item_code, template.item_name, new_code)
if not new_code.item_code or new_code.item_code == variant.item_code:
return
frappe.rename_doc("Item", variant.item_code, new_code.item_code)
# Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so
# item_name is always rebuilt here too, even if it had since been customized away from that pattern.
if new_code.item_name and new_code.item_name != variant.item_name:
frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name)
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
allow_rename_attribute_value = frappe.db.get_single_value(
"Item Variant Settings", "allow_rename_attribute_value"

View File

@@ -6,6 +6,7 @@ import frappe
from frappe import _
from frappe.utils import DateTimeLikeObject, getdate, today
import erpnext
from erpnext.accounts.utils import get_fiscal_year
@@ -42,6 +43,9 @@ def get_columns(filters, trans):
"addl_tables": based_on_details["addl_tables"],
"addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""),
}
conditions["company_currency"] = (
erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None
)
return conditions
@@ -214,7 +218,7 @@ def get_data(filters, conditions):
data.append(des)
total_row = calculate_total_row(data1, conditions["columns"])
total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency"))
data.append(total_row)
else:
data = frappe.db.sql(
@@ -239,20 +243,23 @@ def get_data(filters, conditions):
as_list=1,
)
total_row = calculate_total_row(data, conditions["columns"])
total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency"))
data.append(total_row)
return data
def calculate_total_row(data, columns):
def calculate_total_row(data, columns, company_currency=None):
def wrap_in_quotes(label):
return f"'{label}'"
total_values = {}
currency_col_idx = None
for i, col in enumerate(columns):
if "Float" in col or "Currency/currency" in col:
total_values[i] = 0
if "Link/Currency" in col:
currency_col_idx = i
for row in data:
for i in total_values.keys():
@@ -262,6 +269,9 @@ def calculate_total_row(data, columns):
for i in range(1, len(columns)):
total_row.append(total_values.get(i, None))
if currency_col_idx is not None:
total_row[currency_col_idx] = company_currency
return total_row
@@ -371,7 +381,10 @@ def based_wise_columns_query(based_on, trans):
# based_on_cols, based_on_select, based_on_group_by, addl_tables
if based_on == "Item":
based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"]
based_on_details["based_on_cols"] = [
{"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"},
{"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"},
]
# item_name is an editable per-line field, not functionally dependent on item_code, so it
# is aggregated (one row per item_code) rather than added to GROUP BY (which would split
# the row and change the MariaDB row count). See get_data's group-by query.
@@ -380,7 +393,15 @@ def based_wise_columns_query(based_on, trans):
based_on_details["addl_tables"] = ""
elif based_on == "Item Group":
based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Item Group"),
"fieldtype": "Link",
"options": "Item Group",
"width": 120,
"fieldname": "item_group",
}
]
based_on_details["based_on_select"] = "t2.item_group,"
based_on_details["based_on_group_by"] = "t2.item_group"
based_on_details["addl_tables"] = ""
@@ -388,18 +409,47 @@ def based_wise_columns_query(based_on, trans):
elif based_on == "Customer":
if trans == "Quotation":
based_on_details["based_on_cols"] = [
"Party:Link/Customer:120",
"Party Name:Data:120",
"Territory:Link/Territory:120",
{
"label": _("Party"),
"fieldtype": "Link",
"options": "Customer",
"width": 120,
"fieldname": "party",
},
{"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"},
{
"label": _("Territory"),
"fieldtype": "Link",
"options": "Territory",
"width": 120,
"fieldname": "territory",
},
]
based_on_details[
"based_on_select"
] = "t1.party_name, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory,"
else:
based_on_details["based_on_cols"] = [
"Customer:Link/Customer:120",
"Customer Name:Data:120",
"Territory:Link/Territory:120",
{
"label": _("Customer"),
"fieldtype": "Link",
"options": "Customer",
"width": 120,
"fieldname": "customer",
},
{
"label": _("Customer Name"),
"fieldtype": "Data",
"width": 120,
"fieldname": "customer_name",
},
{
"label": _("Territory"),
"fieldtype": "Link",
"options": "Territory",
"width": 120,
"fieldname": "territory",
},
]
based_on_details[
"based_on_select"
@@ -410,16 +460,35 @@ def based_wise_columns_query(based_on, trans):
based_on_details["addl_tables"] = ""
elif based_on == "Customer Group":
based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"]
based_on_details["based_on_cols"] = [
{
"label": _("Customer Group"),
"fieldtype": "Link",
"options": "Customer Group",
"fieldname": "customer_group",
}
]
based_on_details["based_on_select"] = "t1.customer_group,"
based_on_details["based_on_group_by"] = "t1.customer_group"
based_on_details["addl_tables"] = ""
elif based_on == "Supplier":
based_on_details["based_on_cols"] = [
"Supplier:Link/Supplier:120",
"Supplier Name:Data:120",
"Supplier Group:Link/Supplier Group:140",
{
"label": _("Supplier"),
"fieldtype": "Link",
"options": "Supplier",
"width": 120,
"fieldname": "supplier",
},
{"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"},
{
"label": _("Supplier Group"),
"fieldtype": "Link",
"options": "Supplier Group",
"width": 140,
"fieldname": "supplier_group",
},
]
# supplier_name is a stored per-transaction field (not functionally dependent on supplier), so
# it is aggregated to keep one row per supplier — matching the prior MariaDB output, which grouped
@@ -433,26 +502,58 @@ def based_wise_columns_query(based_on, trans):
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
elif based_on == "Supplier Group":
based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"]
based_on_details["based_on_cols"] = [
{
"label": _("Supplier Group"),
"fieldtype": "Link",
"options": "Supplier Group",
"width": 140,
"fieldname": "supplier_group",
}
]
based_on_details["based_on_select"] = "t3.supplier_group,"
based_on_details["based_on_group_by"] = "t3.supplier_group"
based_on_details["addl_tables"] = ",`tabSupplier` t3"
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
elif based_on == "Territory":
based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Territory"),
"fieldtype": "Link",
"options": "Territory",
"width": 120,
"fieldname": "territory",
}
]
based_on_details["based_on_select"] = "t1.territory,"
based_on_details["based_on_group_by"] = "t1.territory"
based_on_details["addl_tables"] = ""
elif based_on == "Project":
if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]:
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Project"),
"fieldtype": "Link",
"options": "Project",
"width": 120,
"fieldname": "project",
}
]
based_on_details["based_on_select"] = "t1.project,"
based_on_details["based_on_group_by"] = "t1.project"
based_on_details["addl_tables"] = ""
elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]:
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Project"),
"fieldtype": "Link",
"options": "Project",
"width": 120,
"fieldname": "project",
}
]
based_on_details["based_on_select"] = "t2.project,"
based_on_details["based_on_group_by"] = "t2.project"
based_on_details["addl_tables"] = ""
@@ -461,7 +562,15 @@ def based_wise_columns_query(based_on, trans):
based_on_details["based_on_select"] += "t4.default_currency as currency,"
based_on_details["based_on_group_by"] += ", t4.default_currency"
based_on_details["based_on_cols"].append("Currency:Link/Currency:120")
based_on_details["based_on_cols"].append(
{
"label": _("Currency"),
"fieldtype": "Link",
"options": "Currency",
"width": 120,
"fieldname": "currency",
}
)
based_on_details["addl_tables"] += ", `tabCompany` t4"
based_on_details["addl_tables_relational_cond"] = (
based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name"
@@ -472,6 +581,14 @@ def based_wise_columns_query(based_on, trans):
def group_wise_column(group_by):
if group_by:
return [group_by + ":Link/" + group_by + ":120"]
return [
{
"label": _(group_by),
"fieldtype": "Link",
"options": group_by,
"width": 120,
"fieldname": frappe.scrub(group_by),
}
]
else:
return []

View File

@@ -2,6 +2,35 @@
// For license information, please see license.txt
frappe.ui.form.on("CRM Settings", {
// refresh: function(frm) {
// }
refresh: function (frm) {
const flag = frm.events.calculate_visiblity_flag(frm);
frm.set_df_property("allowed_users", "hidden", !flag);
frm.set_df_property("allowed_users", "reqd", flag);
},
enable_frappe_crm_data_synchronization: function (frm) {
const flag = frm.events.calculate_visiblity_flag(frm);
if (flag) {
frappe.show_alert(
__("Allowed Users is required for data synchronization from remote Frappe CRM site.")
);
}
/*
make allowed_users field visible and mandatory if enable_frappe_crm_data_synchronization
is set and crm app is not installed.
*/
frm.set_df_property("allowed_users", "hidden", !flag);
frm.set_df_property("allowed_users", "reqd", flag);
},
calculate_visiblity_flag: function (frm) {
const crm_sync_enabled = frm.doc.enable_frappe_crm_data_synchronization;
const is_crm_installed = cint(frappe.utils.get_installed_apps().includes("crm"));
return crm_sync_enabled && !is_crm_installed;
},
});

View File

@@ -120,9 +120,9 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;",
"fieldname": "allowed_users",
"fieldtype": "Table MultiSelect",
"hidden": 1,
"label": "Allowed Users",
"options": "Frappe CRM Allowed User",
"permlevel": 1
@@ -140,7 +140,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-22 01:26:13.474915",
"modified": "2026-07-01 01:09:16.461470",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",

View File

@@ -6,6 +6,8 @@ from frappe import _
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.model.document import Document
from erpnext.crm.frappe_crm_api import is_crm_installed
class CRMSettings(Document):
# begin: auto-generated types
@@ -46,13 +48,16 @@ class CRMSettings(Document):
)
def validate_allowed_users(self):
if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
if self.enable_frappe_crm_data_synchronization and not (is_crm_installed() or self.allowed_users):
frappe.throw(
_(
"Please add at least one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
)
)
if self.enable_frappe_crm_data_synchronization and is_crm_installed() and self.allowed_users:
frappe.throw(_("Allowed Users is not required as Frappe CRM is already installed on the site."))
def before_save(self):
self.clear_allowed_users()

View File

@@ -1,5 +1,6 @@
import json
import click
import frappe
from frappe import _
@@ -152,7 +153,9 @@ def create_customer(customer_data: dict | None = None):
for field in CUSTOMER_ALLOWED_FIELDS:
if customer_data.get(field) is not None:
customer.set(field, customer_data.get(field))
customer.insert(ignore_permissions=True)
# If CRM is installed on the site, User Permission cannot be ignored while saving Customer Records.
customer.insert(ignore_permissions=not is_crm_installed())
customer_name = customer.name
except Exception:
frappe.db.rollback()
@@ -183,6 +186,10 @@ def validate_frappe_crm_sync():
_("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.")
)
# Skip allowed_users validation if CRM is installed on the site.
if is_crm_installed():
return
allowed_users = [d.user for d in CRMSettings.allowed_users]
if frappe.session.user not in allowed_users:
@@ -192,3 +199,35 @@ def validate_frappe_crm_sync():
),
exc=frappe.PermissionError,
)
def is_crm_installed():
return "crm" in frappe.get_installed_apps()
def remove_allowed_users_on_crm_install():
try:
CRMSettings = frappe.get_single("CRM Settings")
if not CRMSettings.enable_frappe_crm_data_synchronization:
return
CRMSettings.allowed_users = []
CRMSettings.save()
click.secho("Removed 'Allowed Users' from CRM Settings.")
except Exception:
click.secho("'Allowed Users' from CRM Settings couldn't be cleared.")
def disable_frappe_crm_data_synchronization_on_crm_uninstall():
try:
CRMSettings = frappe.get_single("CRM Settings")
if not CRMSettings.enable_frappe_crm_data_synchronization:
return
CRMSettings.enable_frappe_crm_data_synchronization = 0
CRMSettings.save()
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings has been disabled.")
except Exception:
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings could not be disabled.")

View File

@@ -65,6 +65,9 @@ setup_wizard_stages = "erpnext.setup.setup_wizard.setup_wizard.get_setup_stages"
after_install = "erpnext.setup.install.after_install"
after_app_install = "erpnext.setup.install.after_app_install"
after_app_uninstall = "erpnext.setup.install.after_app_uninstall"
boot_session = "erpnext.startup.boot.boot_session"
notification_config = "erpnext.startup.notifications.get_notification_config"
get_help_messages = "erpnext.utilities.activation.get_help_messages"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -586,7 +586,11 @@ frappe.ui.form.on("BOM", {
},
routing(frm) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
// Refetch operations whenever the routing is (re)selected, so that
// changing the routing - e.g. on a new BOM version copied from another
// BOM - replaces the operations with those of the newly selected routing
// instead of keeping the old ones.
if (frm.doc.routing && frm.doc.with_operations) {
frappe.call({
doc: frm.doc,
method: "get_routing",

View File

@@ -1387,13 +1387,29 @@ def _merge_phantom_bom_items(item_dict, item, company, opts):
def _set_default_accounts_for_items(item_dict, company):
fields = [
["Account", "expense_account", "stock_adjustment_account"],
["Cost Center", "cost_center", "cost_center"],
["Warehouse", "default_warehouse", ""],
]
company_of = {}
for d in fields:
names = {item_details.get(d[1]) for item_details in item_dict.values() if item_details.get(d[1])}
company_of[d[0]] = (
{
r.name: r.company
for r in frappe.get_all(
d[0], filters={"name": ("in", list(names))}, fields=["name", "company"]
)
}
if names
else {}
)
for item, item_details in item_dict.items():
for d in [
["Account", "expense_account", "stock_adjustment_account"],
["Cost Center", "cost_center", "cost_center"],
["Warehouse", "default_warehouse", ""],
]:
company_in_record = frappe.db.get_value(d[0], item_details.get(d[1]), "company")
for d in fields:
company_in_record = company_of[d[0]].get(item_details.get(d[1]))
if not item_details.get(d[1]) or (company_in_record and company != company_in_record):
item_dict[item][d[1]] = frappe.get_cached_value("Company", company, d[2]) if d[2] else None

View File

@@ -665,6 +665,48 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(ste.from_bom, 1.0)
self.assertEqual(ste.bom_no, work_order.bom_no)
def test_job_card_material_transfer_via_pick_list(self):
from erpnext.stock.doctype.material_request.mapper import create_pick_list
from erpnext.stock.doctype.pick_list.mapper import (
create_stock_entry as create_stock_entry_from_pick_list,
)
create_bom_with_multiple_operations()
work_order = make_wo_with_transfer_against_jc()
for item in work_order.required_items:
make_stock_entry(
item_code=item.item_code,
target=item.source_warehouse,
qty=item.required_qty * 2,
basic_rate=100,
)
job_card_name = frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
job_card = frappe.get_doc("Job Card", job_card_name)
mr = make_material_request(job_card_name)
mr.schedule_date = today()
mr.submit()
pick_list = create_pick_list(mr.name)
pick_list.submit()
ste = frappe.get_doc(create_stock_entry_from_pick_list(pick_list.as_dict()))
self.assertEqual(ste.purpose, "Material Transfer for Manufacture")
self.assertEqual(ste.job_card, job_card_name)
self.assertEqual(ste.work_order, work_order.name)
self.assertEqual(ste.fg_completed_qty, job_card.for_quantity)
for row in ste.items:
self.assertEqual(row.t_warehouse, job_card.wip_warehouse)
self.assertTrue(row.job_card_item)
ste.insert()
ste.submit()
job_card.reload()
self.assertEqual(job_card.transferred_qty, job_card.for_quantity)
def test_job_card_proccess_qty_and_completed_qty(self):
from erpnext.manufacturing.doctype.routing.test_routing import (
create_routing,

View File

@@ -40,10 +40,29 @@ frappe.ui.form.on("Production Plan", {
});
frm.set_query("for_warehouse", function (doc) {
// when a group is chosen, For Warehouse must be one of its child warehouses
if (doc.raw_material_group_warehouse) {
return {
query: "erpnext.manufacturing.doctype.production_plan.production_plan.get_child_warehouses",
filters: {
group_warehouse: doc.raw_material_group_warehouse,
company: doc.company,
},
};
}
return {
filters: [
["Warehouse", "company", "=", doc.company],
["Warehouse", "is_group", "=", 0],
],
};
});
frm.set_query("raw_material_group_warehouse", function (doc) {
return {
filters: {
company: doc.company,
is_group: 0,
is_group: 1,
},
};
});
@@ -102,6 +121,13 @@ frappe.ui.form.on("Production Plan", {
});
},
raw_material_group_warehouse(frm) {
// For Warehouse must sit inside the chosen group, so drop a stale selection
if (frm.doc.for_warehouse) {
frm.set_value("for_warehouse", null);
}
},
refresh(frm) {
if (frm.doc.docstatus === 1) {
frm.trigger("show_progress");
@@ -451,6 +477,7 @@ frappe.ui.form.on("Production Plan", {
frm.events.get_items_for_material_requests(frm);
} else {
const title = __("Transfer Materials For Warehouse {0}", [frm.doc.for_warehouse]);
const source_warehouse = frm.doc.raw_material_group_warehouse;
var dialog = new frappe.ui.Dialog({
title: title,
fields: [
@@ -459,6 +486,7 @@ frappe.ui.form.on("Production Plan", {
fieldtype: "Table MultiSelect",
fieldname: "warehouses",
options: "Production Plan Material Request Warehouse",
default: source_warehouse ? [{ warehouse: source_warehouse }] : [],
get_query: function () {
return {
filters: {
@@ -515,8 +543,9 @@ frappe.ui.form.on("Production Plan", {
download_materials_required(frm) {
const warehouses_data = [];
if (frm.doc.for_warehouse) {
warehouses_data.push({ warehouse: frm.doc.for_warehouse });
const availability_warehouse = frm.doc.raw_material_group_warehouse || frm.doc.for_warehouse;
if (availability_warehouse) {
warehouses_data.push({ warehouse: availability_warehouse });
}
const fields = [

View File

@@ -52,6 +52,7 @@
"include_safety_stock",
"ignore_existing_ordered_qty",
"column_break_25",
"raw_material_group_warehouse",
"for_warehouse",
"get_items_for_mr",
"transfer_materials",
@@ -318,6 +319,13 @@
"label": "For Warehouse",
"options": "Warehouse"
},
{
"description": "Optional group warehouse. Raw material availability is checked across its child warehouses; material is still received into For Warehouse.",
"fieldname": "raw_material_group_warehouse",
"fieldtype": "Link",
"label": "Raw Material Group Warehouse",
"options": "Warehouse"
},
{
"fieldname": "warehouses",
"fieldtype": "Table MultiSelect",
@@ -445,7 +453,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-08-12 19:48:09.302503",
"modified": "2026-07-07 00:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan",

View File

@@ -102,6 +102,7 @@ class ProductionPlan(Document):
posting_date: DF.Date
prod_plan_references: DF.Table[ProductionPlanItemReference]
project: DF.Link | None
raw_material_group_warehouse: DF.Link | None
reserve_stock: DF.Check
sales_order_status: DF.Literal["", "To Deliver and Bill", "To Bill", "To Deliver"]
sales_orders: DF.Table[ProductionPlanSalesOrder]
@@ -144,8 +145,30 @@ class ProductionPlan(Document):
validate_uom_is_integer(self, "stock_uom", "planned_qty")
self.validate_sales_orders()
self.validate_material_request_type()
self.validate_raw_material_group_warehouse()
self.enable_auto_reserve_stock()
def validate_raw_material_group_warehouse(self):
if not self.raw_material_group_warehouse:
return
group = frappe.db.get_value(
"Warehouse", self.raw_material_group_warehouse, ["lft", "rgt", "is_group"], as_dict=True
)
if not group.is_group:
frappe.throw(
_("{0} must be a group warehouse.").format(frappe.bold(_("Raw Material Group Warehouse")))
)
if self.for_warehouse:
child = frappe.db.get_value("Warehouse", self.for_warehouse, ["lft", "rgt"], as_dict=True)
if not (group.lft <= child.lft and child.rgt <= group.rgt):
frappe.throw(
_("For Warehouse {0} must be a child of the group warehouse {1}.").format(
frappe.bold(self.for_warehouse), frappe.bold(self.raw_material_group_warehouse)
)
)
def enable_auto_reserve_stock(self):
if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
self.reserve_stock = 1
@@ -466,3 +489,26 @@ class ProductionPlan(Document):
def all_items_completed(self):
return SubAssemblyService(self).all_items_completed()
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_child_warehouses(
doctype: str | None, txt: str, searchfield: str | None, start: int, page_len: int, filters: dict
):
"Leaf warehouses under the given group warehouse, for the For Warehouse link query."
bounds = frappe.db.get_value("Warehouse", filters.get("group_warehouse"), ["lft", "rgt"], as_dict=True)
if not bounds:
return []
wh = frappe.qb.DocType("Warehouse")
query = (
frappe.qb.from_(wh)
.select(wh.name)
.where((wh.is_group == 0) & (wh.lft >= bounds.lft) & (wh.rgt <= bounds.rgt))
)
if filters.get("company"):
query = query.where(wh.company == filters.get("company"))
if txt:
query = query.where(wh[searchfield].like(f"%{txt}%"))
return query.limit(page_len).offset(start).run()

View File

@@ -97,6 +97,13 @@ class MaterialRequestService:
def _material_request_item(self, item, material_request_type, schedule_date):
from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None
# a group warehouse cannot receive stock; it must never reach a Material Request line
if item.warehouse and frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
frappe.throw(
_("Cannot create Material Request for item {0} in group warehouse {1}.").format(
frappe.bold(item.item_code), frappe.bold(item.warehouse)
)
)
project = (
frappe.db.get_value("Sales Order", item.sales_order, "project") if item.sales_order else None
)
@@ -132,13 +139,14 @@ class MaterialRequestService:
@frappe.whitelist()
def get_items_for_material_requests(
doc: str | frappe._dict | Document,
doc: str | dict | Document,
warehouses: str | list | None = None,
get_parent_warehouse_data: bool | int | None = None,
):
frappe.has_permission("Production Plan", "read", throw=True)
doc = _normalize_mr_doc(doc)
_validate_group_warehouse_target(doc)
warehouses = _filter_warehouses(doc, warehouses, get_parent_warehouse_data)
doc["mr_items"] = []
@@ -163,6 +171,17 @@ def _normalize_mr_doc(doc):
return doc
def _validate_group_warehouse_target(doc):
# the group only scopes availability; raw materials still need a concrete
# receiving warehouse, so for_warehouse is required once we generate items.
if doc.get("raw_material_group_warehouse") and not doc.get("for_warehouse"):
frappe.throw(
_("{0} is required to get raw materials when {1} is set.").format(
frappe.bold(_("For Warehouse")), frappe.bold(_("Raw Material Group Warehouse"))
)
)
def _filter_warehouses(doc, warehouses, get_parent_warehouse_data):
if not warehouses:
return warehouses
@@ -355,13 +374,18 @@ def _accumulate_so_items(so_item_details, sales_order, item_details, qty_precisi
def _build_mr_items(doc, so_item_details, ignore_ordered_qty):
mr_items = []
consumed_qty = defaultdict(float)
warehouse = doc.get("for_warehouse")
# raw_material_group_warehouse (optional, group) only widens the availability
# scope to its child warehouses; material is still received into for_warehouse.
target_warehouse = doc.get("for_warehouse")
scope_warehouse = doc.get("raw_material_group_warehouse") or target_warehouse
company = doc.get("company")
include_safety_stock = doc.get("include_safety_stock")
for sales_order, item_dict in so_item_details.items():
for details in item_dict.values():
warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse")
fallback = details.get("source_warehouse") or details.get("default_warehouse")
scope_warehouse = scope_warehouse or fallback
target_warehouse = target_warehouse or fallback
row = _mr_item_for_details(
doc,
details,
@@ -369,7 +393,8 @@ def _build_mr_items(doc, so_item_details, ignore_ordered_qty):
company,
ignore_ordered_qty,
include_safety_stock,
warehouse,
scope_warehouse,
target_warehouse,
consumed_qty,
)
if row:
@@ -378,10 +403,19 @@ def _build_mr_items(doc, so_item_details, ignore_ordered_qty):
def _mr_item_for_details(
doc, details, sales_order, company, ignore_ordered_qty, include_safety_stock, warehouse, consumed_qty
doc,
details,
sales_order,
company,
ignore_ordered_qty,
include_safety_stock,
warehouse,
target_warehouse,
consumed_qty,
):
bin_dict = get_bin_details(details, doc.company, warehouse)
bin_dict = bin_dict[0] if bin_dict else {}
# get_bin_details scopes to the warehouse's descendants, returning one row per
# child warehouse; sum them so a group warehouse reflects combined child stock.
bin_dict = _aggregate_bin_details(get_bin_details(details, doc.company, warehouse))
if details.qty <= 0:
return None
return get_material_request_items(
@@ -392,11 +426,27 @@ def _mr_item_for_details(
ignore_ordered_qty,
include_safety_stock,
warehouse,
target_warehouse,
bin_dict,
consumed_qty,
)
def _aggregate_bin_details(bin_list):
qty_fields = (
"projected_qty",
"actual_qty",
"ordered_qty",
"reserved_qty_for_production",
"planned_qty",
)
aggregated = {field: 0 for field in qty_fields}
for row in bin_list or []:
for field in qty_fields:
aggregated[field] += flt(row.get(field))
return aggregated
def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data):
if not ((ignore_ordered_qty or get_parent_warehouse_data) and warehouses):
return mr_items
@@ -428,6 +478,7 @@ def get_material_request_items(
ignore_existing_ordered_qty,
include_safety_stock,
warehouse,
target_warehouse,
bin_dict,
consumed_qty,
):
@@ -438,7 +489,7 @@ def get_material_request_items(
item_group_defaults = get_item_group_defaults(row.item_code, company)
conversion_factor = _mr_purchase_conversion_factor(row)
return _material_request_item_row(
row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
row, sales_order, target_warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
)

View File

@@ -1592,6 +1592,104 @@ class TestProductionPlan(ERPNextTestSuite):
for row in plan.mr_items:
self.assertFalse(row.from_warehouse)
def _setup_group_rm_warehouse(self):
"""FG + RM with a group raw-material warehouse (C1, C2) partially stocked (3 + 4)."""
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
group_warehouse = "_Test Warehouse Group - _TC"
child_1 = "_Test Warehouse Group-C1 - _TC"
child_2 = "_Test Warehouse Group-C2 - _TC"
fg_item = "Test PP Group FG"
rm_item = "Test PP Group RM"
create_item(rm_item, valuation_rate=100)
create_item(fg_item, valuation_rate=100)
if not frappe.db.get_value("BOM", {"item": fg_item, "is_active": 1}):
create_nested_bom({fg_item: {rm_item: {}}}, prefix="")
make_stock_entry(item_code=rm_item, qty=3, rate=100, target=child_1)
make_stock_entry(item_code=rm_item, qty=4, rate=100, target=child_2)
return frappe._dict(
group_warehouse=group_warehouse,
children={child_1, child_2},
for_wh=child_1, # a leaf inside the group, used as For Warehouse
fg_item=fg_item,
rm_item=rm_item,
)
def test_group_raw_material_warehouse_aggregates_child_stock(self):
"Combined child stock (3 + 4) is used as projected qty; material targets For Warehouse."
data = self._setup_group_rm_warehouse()
plan = create_production_plan(
item_code=data.fg_item,
planned_qty=10,
for_warehouse=data.for_wh,
raw_material_group_warehouse=data.group_warehouse,
do_not_save=1,
skip_getting_mr_items=1,
)
mr_items = get_items_for_material_requests(plan.as_dict())
rm_rows = [d for d in mr_items if d.get("item_code") == data.rm_item]
self.assertEqual(len(rm_rows), 1)
# projected qty reflects the sum across both child warehouses, not a single child
self.assertEqual(flt(rm_rows[0].get("projected_qty")), 7.0)
# the group is only an availability scope; the row targets For Warehouse
self.assertEqual(rm_rows[0].get("warehouse"), data.for_wh)
def test_group_raw_material_warehouse_transfers_from_child_warehouses(self):
"Material is transferred only from actual child warehouses, never the group node."
data = self._setup_group_rm_warehouse()
plan = create_production_plan(
item_code=data.fg_item,
planned_qty=10,
ignore_existing_ordered_qty=1,
for_warehouse=data.for_wh,
raw_material_group_warehouse=data.group_warehouse,
do_not_save=1,
skip_getting_mr_items=1,
)
mr_items = get_items_for_material_requests(
plan.as_dict(), warehouses=[{"warehouse": data.group_warehouse}]
)
transfer_rows = [d for d in mr_items if d.get("material_request_type") == "Material Transfer"]
self.assertTrue(transfer_rows)
for row in transfer_rows:
self.assertIn(row.get("from_warehouse"), data.children)
for row in mr_items:
# a group warehouse must never be a Material Request target
self.assertNotEqual(row.get("warehouse"), data.group_warehouse)
def test_for_warehouse_must_be_child_of_group(self):
"A For Warehouse outside the chosen group warehouse is rejected on save."
data = self._setup_group_rm_warehouse()
plan = create_production_plan(
item_code=data.fg_item,
planned_qty=10,
for_warehouse="_Test Warehouse - _TC", # outside the group
raw_material_group_warehouse=data.group_warehouse,
do_not_save=1,
skip_getting_mr_items=1,
)
self.assertRaises(frappe.ValidationError, plan.save)
def test_for_warehouse_required_with_group_when_getting_raw_materials(self):
"A group warehouse without a For Warehouse is rejected when raw materials are fetched."
data = self._setup_group_rm_warehouse()
plan = create_production_plan(
item_code=data.fg_item,
planned_qty=10,
raw_material_group_warehouse=data.group_warehouse,
skip_getting_mr_items=1,
)
self.assertRaises(frappe.ValidationError, get_items_for_material_requests, plan.as_dict())
def test_skip_available_qty_for_sub_assembly_items(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
@@ -3122,6 +3220,7 @@ def create_production_plan(**args):
"sub_assembly_warehouse": args.sub_assembly_warehouse,
"reserve_stock": args.reserve_stock or 0,
"for_warehouse": args.for_warehouse or None,
"raw_material_group_warehouse": args.raw_material_group_warehouse or None,
}
)

View File

@@ -515,6 +515,41 @@ def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished
target.conversion_factor = 1
@frappe.whitelist()
def make_material_request(source_name: str, target_doc: str | dict | None = None):
frappe.has_permission("Material Request", "create", throw=True)
doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc)
doc.material_request_type = "Material Transfer"
return doc
def _material_request_mapping():
return {
"Work Order": {
"doctype": "Material Request",
"validation": {"docstatus": ["=", 1]},
"field_map": {"name": "work_order"},
},
"Work Order Item": {
"doctype": "Material Request Item",
"field_map": [
("required_qty", "qty"),
("stock_uom", "uom"),
("source_warehouse", "from_warehouse"),
],
"postprocess": _set_material_request_item,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
},
}
def _set_material_request_item(source, target, source_parent):
target.warehouse = source_parent.wip_warehouse
target.qty = flt(source.required_qty) - flt(source.transferred_qty)
target.schedule_date = nowdate()
@frappe.whitelist()
def make_stock_return_entry(work_order: str):
from erpnext.stock.doctype.stock_entry.services.manufacturing import (

View File

@@ -145,9 +145,18 @@ class StatusService:
def _has_transferred_material(self):
"""True if any raw material was transferred against this work order via a pick list
(these leave material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
or a material request (these leave material_transferred_for_manufacturing at 0 via
the min-fraction rule)."""
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
mr_child = frappe.qb.DocType("Stock Entry Detail")
# Stock Entry only carries `material_request` at the child-row level, so a Stock
# Entry is "MR-sourced" if *any* of its rows link back to a Material Request; once
# that's established, sum every row's transfer_qty, not just the linked ones (a
# manually appended extra row on the same entry has no material_request of its own).
mr_sourced_stock_entries = (
frappe.qb.from_(mr_child).select(mr_child.parent).where(mr_child.material_request.isnotnull())
)
qty = (
frappe.qb.from_(ste)
.inner_join(ste_child)
@@ -158,7 +167,7 @@ class StatusService:
& (ste.docstatus == 1)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 0)
& (ste.pick_list.isnotnull())
& (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries))
)
).run()[0][0]
return flt(qty) > 0

View File

@@ -12,6 +12,7 @@ from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
from erpnext.manufacturing.doctype.job_card.mapper import make_stock_entry as make_stock_entry_from_jc
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.mapper import (
make_material_request,
make_stock_entry,
make_stock_return_entry,
)
@@ -1582,6 +1583,61 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_work_order_material_request_and_bom_details(self):
from erpnext.stock.doctype.material_request.mapper import make_stock_entry as mr_to_stock_entry
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
)
mr = make_material_request(work_order.name)
mr.schedule_date = today()
for item in mr.items:
item.schedule_date = today()
mr.submit()
self.assertEqual(mr.work_order, work_order.name)
ste = mr_to_stock_entry(mr.name)
self.assertEqual(ste.purpose, "Material Transfer for Manufacture")
self.assertEqual(ste.work_order, work_order.name)
self.assertEqual(ste.from_bom, 1.0)
self.assertEqual(ste.bom_no, work_order.bom_no)
self.assertEqual(ste.fg_completed_qty, 0.0)
def test_status_in_process_when_only_one_required_item_transferred_via_material_request(self):
"""Same bottleneck scenario as the Pick List flow, but the intermediate document is a
Material Request created directly from the Work Order: min-fraction keeps
material_transferred_for_manufacturing at 0, but the work order must still move to
In Process because material is already in WIP.
"""
from erpnext.stock.doctype.material_request.mapper import make_stock_entry as mr_to_stock_entry
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
)
mr = make_material_request(work_order.name)
mr.schedule_date = today()
# request only _Test Item; the other required item is left off this material request
mr.items = [item for item in mr.items if item.item_code == "_Test Item"]
for item in mr.items:
item.schedule_date = today()
mr.submit()
stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name))
self.assertEqual(stock_entry.fg_completed_qty, 0.0)
stock_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",

View File

@@ -822,6 +822,10 @@ erpnext.work_order = {
erpnext.work_order.create_pick_list(frm);
});
frm.add_custom_button(__("Material Request"), function () {
erpnext.work_order.make_material_request(frm);
});
var start_btn = frm.add_custom_button(__("Start"), function () {
erpnext.work_order.make_se(frm, "Material Transfer for Manufacture");
});
@@ -1157,6 +1161,13 @@ erpnext.work_order = {
}
},
make_material_request: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
frm,
});
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
const max = this.get_max_transferable_qty(frm, purpose);

View File

@@ -11,6 +11,7 @@ from frappe.query_builder.functions import Coalesce, IfNull, Sum
from frappe.utils import (
cint,
flt,
get_datetime,
get_link_to_form,
now,
nowdate,
@@ -34,6 +35,7 @@ from erpnext.manufacturing.doctype.work_order.mapper import (
get_template_rm_item,
get_work_order_operation_data,
make_job_card,
make_material_request,
make_stock_entry,
make_stock_return_entry,
make_work_order,
@@ -317,6 +319,10 @@ class WorkOrder(Document):
self.validate_subcontracting_inward_order()
def validate_dates(self):
if self.planned_start_date and self.planned_end_date:
if get_datetime(self.planned_end_date) < get_datetime(self.planned_start_date):
frappe.throw(_("Planned End Date cannot be before Planned Start Date"))
if self.actual_start_date and self.actual_end_date:
if self.actual_end_date < self.actual_start_date:
frappe.throw(_("Actual End Date cannot be before Actual Start Date"))

View File

@@ -432,7 +432,7 @@
"type": "Link"
}
],
"modified": "2026-07-03 13:44:07.420267",
"modified": "2026-07-05 16:32:01.858579",
"modified_by": "Administrator",
"module": "Manufacturing",
"module_onboarding": "Manufacturing Onboarding",
@@ -463,6 +463,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "house",
"indent": 0,
"keep_closed": 0,
@@ -476,6 +477,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
@@ -489,6 +491,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "list-tree",
"indent": 0,
"keep_closed": 0,
@@ -502,6 +505,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "factory",
"indent": 0,
"keep_closed": 0,
@@ -515,6 +519,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "person-standing",
"indent": 0,
"keep_closed": 0,
@@ -528,6 +533,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "package",
"indent": 0,
"keep_closed": 0,
@@ -541,6 +547,20 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Shop Floor",
"link_to": "shop-floor",
"link_type": "Page",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "rocket",
"indent": 1,
"keep_closed": 1,
@@ -553,6 +573,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -566,6 +587,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Plan",
@@ -578,6 +600,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -591,6 +614,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Master Production Schedule",
@@ -603,6 +627,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Sales Forecast",
@@ -615,6 +640,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
@@ -627,6 +653,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "wrench",
"indent": 1,
"keep_closed": 1,
@@ -639,6 +666,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Creator",
@@ -651,6 +679,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Update Tool",
@@ -663,6 +692,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Comparison Tool",
@@ -675,6 +705,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Entry",
@@ -687,6 +718,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "notepad-text",
"indent": 1,
"keep_closed": 1,
@@ -699,6 +731,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
@@ -711,6 +744,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Summary",
@@ -723,6 +757,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Quality Inspection Summary",
@@ -735,6 +770,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Analysis",
@@ -747,6 +783,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Job Card Summary",
@@ -759,6 +796,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Search",
@@ -771,6 +809,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Analytics",
@@ -783,6 +822,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Operations Time",
@@ -795,6 +835,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Consumed Materials",
@@ -807,6 +848,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "database",
"indent": 1,
"keep_closed": 1,
@@ -819,6 +861,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -832,6 +875,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -845,6 +889,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Operation",
@@ -857,6 +902,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -870,6 +916,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Workstation Type",
@@ -882,6 +929,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Plant Floor",
@@ -894,6 +942,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Routing",
@@ -906,6 +955,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "settings",
"indent": 0,
"keep_closed": 0,

View File

@@ -260,7 +260,6 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta
erpnext.patches.v14_0.update_proprietorship_to_individual
erpnext.patches.v15_0.rename_subcontracting_fields
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
erpnext.patches.v16_0.create_company_custom_fields
erpnext.patches.v16_0.convert_commission_rate_to_percent
[post_model_sync]
@@ -440,6 +439,7 @@ erpnext.patches.v16_0.set_reporting_currency
erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes
erpnext.patches.v16_0.update_serial_no_reference_name
erpnext.patches.v16_0.update_account_categories_for_existing_accounts
erpnext.patches.v16_0.create_company_custom_fields
erpnext.patches.v16_0.rename_subcontracted_quantity
erpnext.patches.v16_0.add_new_stock_entry_types
erpnext.patches.v15_0.set_asset_status_if_not_already_set
@@ -494,3 +494,6 @@ erpnext.patches.v16_0.set_default_close_opportunity_after_days
execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
erpnext.patches.v16_0.create_shop_floor_roles
erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field
erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm

View File

@@ -0,0 +1,21 @@
import frappe
def execute():
for custom_field in frappe.get_all(
"Custom Field",
filters={
"fieldname": "service_level_agreement",
"fieldtype": "Link",
"options": "Service Level Agreement",
"link_filters": ("is", "not set"),
},
fields=["name", "dt"],
):
link_filters = frappe.as_json(
[["Service Level Agreement", "document_type", "=", custom_field.dt]], indent=None
)
frappe.db.set_value(
"Custom Field", custom_field.name, "link_filters", link_filters, update_modified=False
)
frappe.clear_cache(doctype=custom_field.dt)

View File

@@ -0,0 +1,20 @@
import frappe
def execute():
for docfield in frappe.get_all(
"DocField",
filters={
"parenttype": "DocType",
"fieldname": "service_level_agreement",
"fieldtype": "Link",
"options": "Service Level Agreement",
"link_filters": ("is", "not set"),
},
fields=["name", "parent"],
):
link_filters = frappe.as_json(
[["Service Level Agreement", "document_type", "=", docfield.parent]], indent=None
)
frappe.db.set_value("DocField", docfield.name, "link_filters", link_filters, update_modified=False)
frappe.clear_cache(doctype=docfield.parent)

View File

@@ -0,0 +1,10 @@
import frappe
def execute():
from erpnext.crm.frappe_crm_api import is_crm_installed, remove_allowed_users_on_crm_install
if not is_crm_installed():
return
remove_allowed_users_on_crm_install()

View File

@@ -40,7 +40,32 @@ erpnext.financial_statements = {
_is_special_view: function (column, data) {
if (!data) return false;
const view = get_filter_value("selected_view");
if (!["Growth", "Margin"].includes(view)) return false;
// First period of each dim has no prior in Growth → show raw currency, not %.
// Margin always shows % for all period columns (income row = 100%).
if (view === "Growth" && column.is_first_in_dimension) return false;
if (get_filter_value("report_template")) {
const columnInfo = erpnext.financial_statements._parse_column_info(column.fieldname, data);
// Account column
if (columnInfo.isAccount) return false;
const periodKeys = data._segment_info?.period_keys || [];
if (!periodKeys.includes(columnInfo.fieldname)) return false;
if (view === "Growth") {
// First period of new segment
if (periodKeys[0] === columnInfo.fieldname) return false;
}
return true;
}
return (view === "Growth" && column.colIndex >= 3) || (view === "Margin" && column.colIndex >= 2);
},
@@ -372,6 +397,18 @@ erpnext.financial_statements = {
});
}
},
get_accounting_dimension_options: function () {
const options = ["", "Cost Center", "Project"];
frappe.db
.get_list("Accounting Dimension", { fields: ["document_type"], filters: { disabled: 0 } })
.then((res) => {
res.forEach((dimension) => {
options.push(dimension.document_type);
});
});
return options;
},
};
function get_filters() {

View File

@@ -80,10 +80,10 @@ class ShopFloor {
<div class="sf-topbar-right">
<button class="btn btn-default btn-sm sf-btn-theme"></button>
<button class="btn btn-default btn-sm sf-btn-home" title="${__("Home")}">
${frappe.utils.icon("home", "sm")}
${frappe.utils.icon("house", "sm")}
</button>
<button class="btn btn-default btn-sm sf-btn-refresh" title="${__("Refresh")} (r)">
${frappe.utils.icon("refresh", "sm")}
${frappe.utils.icon("refresh-cw", "sm")}
</button>
<button class="btn btn-default btn-sm sf-btn-scan" title="${__("Scan Job Card")} (b)">
${frappe.utils.icon("scan", "sm")}
@@ -100,6 +100,9 @@ class ShopFloor {
`);
this.app = this.wrapper.find(".sf-app");
this.brand_icon = `<img class="sf-brand-icon" src="/assets/erpnext/images/erpnext-logo.svg" alt="${__(
"ERPNext"
)}">`;
this.topbar_left = this.wrapper.find(".sf-topbar-left");
this.topbar_center = this.wrapper.find(".sf-topbar-center");
this.body = this.wrapper.find(".sf-body");
@@ -154,7 +157,7 @@ class ShopFloor {
if (this.view === "manager") {
this.topbar_left.html(`
<span class="sf-title">${__("Shop Floor")}</span>
<span class="sf-title">${this.brand_icon}${__("Shop Floor")}</span>
${toggle}
<div class="sf-tabs">
${MANAGER_BUCKETS.map(
@@ -191,7 +194,9 @@ class ShopFloor {
this.toggle_job_cards_only(e.target.checked);
});
} else {
this.topbar_left.html(`<span class="sf-title">${__("Shop Floor")}</span>${toggle}`);
this.topbar_left.html(
`<span class="sf-title">${this.brand_icon}${__("Shop Floor")}</span>${toggle}`
);
this.build_operator_filters();
}
@@ -466,11 +471,19 @@ class ShopFloor {
this.workstation = workstation;
this.work_order = work_order;
this.compute_state();
this.dedupe_today_sessions();
this.render_operator($container);
},
});
}
// A job card already shown under Completed Operations shouldn't repeat in
// Today's Sessions — keep it in Completed Operations only.
dedupe_today_sessions() {
const shown = new Set((this.completed || []).map((jc) => jc.name));
this.today_sessions = (this.today_sessions || []).filter((s) => !shown.has(s.name));
}
// Re-fetch whichever operator content is currently on screen (used after every action).
reload() {
if (this.view === "manager" && this.selected_wo) {
@@ -1570,7 +1583,8 @@ class ShopFloor {
.sf-toggle input { cursor: pointer; width: 15px; height: 15px; margin: 0; }
.sf-topbar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.sf-btn-theme { font-size: 15px; line-height: 1; min-width: 30px; }
.sf-title { font-size: 18px; font-weight: 700; color: var(--text-color); }
.sf-title { font-size: 18px; font-weight: 700; color: var(--text-color); display: inline-flex; align-items: center; gap: 8px; }
.sf-title .sf-brand-icon { flex-shrink: 0; width: 22px; height: 22px; border-radius: 5px; }
.sf-view-toggle { display: inline-flex; border: 1px solid var(--border-color); border-radius: var(--border-radius); overflow: hidden; }
.sf-view-btn { border: none; background: var(--fg-color); padding: 5px 12px; font-size: 13px; color: var(--text-muted); cursor: pointer; }
@@ -1584,6 +1598,7 @@ class ShopFloor {
font-size: 14px; color: var(--text-muted); cursor: pointer;
}
.sf-tab:hover { background: var(--bg-color); }
.sf-tab:focus, .sf-tab:focus-visible { outline: none; box-shadow: none; }
.sf-tab.active { background: var(--control-bg); color: var(--text-color); font-weight: 600; }
.sf-tab-count { font-variant-numeric: tabular-nums; color: var(--text-muted); }
.sf-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
@@ -1637,10 +1652,10 @@ class ShopFloor {
drawn with it disappears on dark cards. Light theme keeps the dark ring; dark theme
needs an accent colour — a light-gray ring on gray cards is still too subtle. */
.sf-wo-card.sf-selected { border-color: var(--primary-color, var(--primary)); }
.sf-wo-card.sf-focused { box-shadow: 0 0 0 2px var(--primary-color, var(--primary)); }
.sf-wo-card.sf-focused { box-shadow: 0 0 0 1px var(--primary-color, var(--primary)); }
[data-theme="dark"] .sf-wo-card.sf-selected { border-color: var(--blue-500, #2490ef); }
[data-theme="dark"] .sf-wo-card.sf-focused {
box-shadow: 0 0 0 3px var(--blue-500, #2490ef);
box-shadow: 0 0 0 1px var(--blue-500, #2490ef);
border-color: transparent;
}
.sf-wo-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
@@ -1666,7 +1681,7 @@ class ShopFloor {
.sf-wo-progress-block { margin-bottom: 12px; }
.sf-wo-progress-label { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--text-muted); margin-bottom: 6px; }
.sf-wo-progress-count { font-weight: 600; color: var(--text-color); font-variant-numeric: tabular-nums; }
.sf-progress { display: flex; height: 10px; border-radius: 6px; background: var(--gray-300, #d1d5db); overflow: hidden; }
.sf-progress { display: flex; height: 10px; border-radius: 6px; background: var(--gray-200, #d1d5db); overflow: hidden; }
[data-theme="dark"] .sf-progress { background: var(--gray-700, #374151); }
.sf-progress-seg { height: 100%; transition: width 0.3s ease; }
.sf-seg-done { background: var(--green-400, #9ae6b4); }

View File

@@ -83,7 +83,11 @@
/* Active job card — denser than before */
/* Keyboard focus highlight for arrow-key navigation in the operator view. */
[data-sf-focusable].sf-focused { box-shadow: 0 0 0 2px var(--primary); border-radius: var(--border-radius); outline: none; }
[data-sf-focusable].sf-focused {
box-shadow: 0 0 0 1px var(--primary-color, var(--primary)); border-radius: var(--border-radius); outline: none;
border: 1px solid var(--border-color);
border-radius: 12px;
}
.mes-job {
border: 1px solid var(--border-color);
background: var(--fg-color);

View File

@@ -562,6 +562,18 @@ $.extend(erpnext.utils, {
},
});
erpnext.utils.confirm_negative_stock = function (frm) {
if (!frm.doc.allow_negative_stock) return;
frappe.confirm(
__(
"Using negative stock disables FIFO/Moving average valuation when inventory is negative. This is considered dangerous from accounting point of view.<br>Do you still want to enable negative inventory?"
),
() => {},
() => frm.set_value("allow_negative_stock", 0)
);
};
erpnext.utils.select_alternate_items = function (opts) {
const frm = opts.frm;
const warehouse_field = opts.warehouse_field || "warehouse";

View File

@@ -1368,7 +1368,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
frappe.throw(__("Please select at least one item to continue"));
}
me.frm.call({
method: "make_work_orders",
method: "erpnext.selling.doctype.sales_order.mapper.make_work_orders",
args: {
items: data,
company: me.frm.doc.company,

View File

@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe import _
from erpnext.controllers.trends import get_columns, get_data
@@ -40,9 +39,15 @@ def get_chart_data(data, conditions, filters):
labels = [column.split(":")[0] for column in columns]
datapoints = [0] * len(labels)
group_by_col_idx = None
if filters.get("group_by"):
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
for row in data:
# If group by filter, don't add first row of group (it's already summed)
if not row[start]:
# Skip the final grand-total row
if row[0] == f"'{_('Total')}'":
continue
if group_by_col_idx is not None and row[group_by_col_idx] == "":
continue
# Remove None values and compute only periodic data
row = [x if x else 0 for x in row[start:-2]]
@@ -59,4 +64,6 @@ def get_chart_data(data, conditions, filters):
"type": "line",
"lineOptions": {"regionFill": 1},
"fieldtype": "Currency",
"options": "currency",
"currency": conditions.get("company_currency"),
}

Some files were not shown because too many files have changed in this diff Show More