mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-15 23:48:38 +00:00
Merge branch 'develop' into refactor-stock-gl-composer
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}`];
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
57
erpnext/accounts/services/deferred_accounting.py
Normal file
57
erpnext/accounts/services/deferred_accounting.py
Normal 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"))
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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"
|
||||
|
||||
3665
erpnext/locale/ar.po
3665
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3657
erpnext/locale/bg.po
3657
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
3797
erpnext/locale/bs.po
3797
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/cs.po
3659
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
3657
erpnext/locale/da.po
3657
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/de.po
3665
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/eo.po
3665
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3663
erpnext/locale/es.po
3663
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
3803
erpnext/locale/fa.po
3803
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
3661
erpnext/locale/fr.po
3661
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
3657
erpnext/locale/hi.po
3657
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3681
erpnext/locale/hr.po
3681
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/hu.po
3659
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/id.po
3659
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/it.po
3659
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
3661
erpnext/locale/ko.po
3661
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
3657
erpnext/locale/my.po
3657
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
3657
erpnext/locale/nb.po
3657
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
3663
erpnext/locale/nl.po
3663
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
3657
erpnext/locale/pl.po
3657
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/pt.po
3659
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/ru.po
3665
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/sl.po
3659
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/sr.po
3665
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3787
erpnext/locale/sv.po
3787
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/th.po
3665
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
3663
erpnext/locale/tr.po
3663
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
22149
erpnext/locale/uz.po
22149
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/vi.po
3665
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/zh.po
3665
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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() {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user