mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-16 07:58:38 +00:00
Merge branch 'develop' into fix-flaky-usd-exchange-rate-tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}`];
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,6 +88,7 @@ def execute(filters=None):
|
||||
"parent_section": None,
|
||||
"indent": 0.0,
|
||||
"section": cash_flow_section["section_header"],
|
||||
"currency": company_currency,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
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")
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: hello@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-07-05 10:19+0000\n"
|
||||
"PO-Revision-Date: 2026-07-08 21:28\n"
|
||||
"PO-Revision-Date: 2026-07-09 21:42\n"
|
||||
"Last-Translator: hello@frappe.io\n"
|
||||
"Language-Team: Bosnian\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -2487,7 +2487,7 @@ msgstr "Trošak Aktivnosti postoji za {0} u odnosu na vrstu aktivnosti - {1}"
|
||||
|
||||
#: erpnext/projects/doctype/activity_type/activity_type.js:10
|
||||
msgid "Activity Cost per Employee"
|
||||
msgstr "Trošak aktivnosti po personalu"
|
||||
msgstr "Trošak Aktivnosti po Osoblju"
|
||||
|
||||
#. Label of the activity_type (Link) field in DocType 'Sales Invoice Timesheet'
|
||||
#. Label of the activity_type (Link) field in DocType 'Activity Cost'
|
||||
@@ -2724,7 +2724,7 @@ msgstr "Dodaj popust"
|
||||
|
||||
#: erpnext/public/js/event.js:40
|
||||
msgid "Add Employees"
|
||||
msgstr "Dodaj Personal"
|
||||
msgstr "Dodaj Osoblje"
|
||||
|
||||
#: erpnext/public/js/bom_configurator/bom_configurator.bundle.js:256
|
||||
#: erpnext/selling/doctype/sales_order/sales_order.js:278
|
||||
@@ -3896,7 +3896,7 @@ msgstr "Svi odjeli"
|
||||
#. Option for the 'Send To' (Select) field in DocType 'SMS Center'
|
||||
#: erpnext/selling/doctype/sms_center/sms_center.json
|
||||
msgid "All Employee (Active)"
|
||||
msgstr "Sav Personal (Aktivni)"
|
||||
msgstr "Sve Osoblje (Aktivno)"
|
||||
|
||||
#: erpnext/setup/doctype/item_group/item_group.py:35
|
||||
#: erpnext/setup/doctype/item_group/item_group.py:36
|
||||
@@ -3934,7 +3934,7 @@ msgstr "Kontakt svih prodajnih partnera"
|
||||
#. Option for the 'Send To' (Select) field in DocType 'SMS Center'
|
||||
#: erpnext/selling/doctype/sms_center/sms_center.json
|
||||
msgid "All Sales Person"
|
||||
msgstr "Sav Prodajni Personal"
|
||||
msgstr "Sve Prodajno Osoblje"
|
||||
|
||||
#. Description of a DocType
|
||||
#: erpnext/setup/doctype/sales_person/sales_person.json
|
||||
@@ -5212,7 +5212,7 @@ msgstr "Primjenjivo na (Pozicija)"
|
||||
#. Label of the to_emp (Link) field in DocType 'Authorization Rule'
|
||||
#: erpnext/setup/doctype/authorization_rule/authorization_rule.json
|
||||
msgid "Applicable To (Employee)"
|
||||
msgstr "Primjenjivo na (Personal)"
|
||||
msgstr "Primjenjivo na (Osoblje)"
|
||||
|
||||
#. Label of the system_role (Link) field in DocType 'Authorization Rule'
|
||||
#: erpnext/setup/doctype/authorization_rule/authorization_rule.json
|
||||
@@ -6227,7 +6227,7 @@ msgstr "Imovina {assets_link} kreirana za {item_code}"
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.js:712
|
||||
msgid "Assign Job to Employee"
|
||||
msgstr "Dodijeli Posao Personalu"
|
||||
msgstr "Dodijeli Posao Osoblju"
|
||||
|
||||
#. Label of the assign_to_name (Read Only) field in DocType 'Asset Maintenance
|
||||
#. Task'
|
||||
@@ -9715,7 +9715,7 @@ msgstr "Nije moguće spojiti"
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.py:292
|
||||
msgid "Cannot Relieve Employee"
|
||||
msgstr "Nije moguće razriješiti Personal"
|
||||
msgstr "Nije moguće Razriješiti Osoblje"
|
||||
|
||||
#: erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py:71
|
||||
msgid "Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."
|
||||
@@ -11917,7 +11917,7 @@ msgstr "Poduzeće imovine {0} i nabavni dokument {1} ne odgovara."
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.py:164
|
||||
msgid "Company or Personal Email is mandatory when 'Create User Automatically' is enabled"
|
||||
msgstr "E-mail poduzeća ili lični e-mail je obavezan kada je omogućena opcija \"Automatski Kreiraj Korisnika\""
|
||||
msgstr "E-mail poduzeća ili lični e-mail je obavezan kada je omogućena opcija \"Automatski Izradi Osoblje\""
|
||||
|
||||
#. Description of the 'Registration Details' (Code) field in DocType 'Company'
|
||||
#: erpnext/setup/doctype/company/company.json
|
||||
@@ -13477,15 +13477,15 @@ msgstr "Kreiraj Dostavni Put"
|
||||
|
||||
#: erpnext/utilities/activation.py:139
|
||||
msgid "Create Employee"
|
||||
msgstr "Kreiraj Personal"
|
||||
msgstr "Izradi Osoblje"
|
||||
|
||||
#: erpnext/utilities/activation.py:137
|
||||
msgid "Create Employee Records"
|
||||
msgstr "Kreiraj Personalni Registar"
|
||||
msgstr "Izradi Registar Osoblja"
|
||||
|
||||
#: erpnext/utilities/activation.py:138
|
||||
msgid "Create Employee records."
|
||||
msgstr "Kreiraj Personalni Registar"
|
||||
msgstr "Izradi Registar Osoblja."
|
||||
|
||||
#. Title of an Onboarding Step
|
||||
#. Label of an action in the Onboarding Step 'Create Existing Asset'
|
||||
@@ -13902,7 +13902,7 @@ msgstr "Kreirano {0} tablica bodova za {1} između:"
|
||||
#. 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Creates a User account for this employee using the Preferred, Company, or Personal email."
|
||||
msgstr "Kreira korisnički račun za personal koristeći preferiranu, poduzeća ili ličnu e-poštu."
|
||||
msgstr "Izradi korisnički račun za Osoblje koristeći Preferiranu, Poduzeća ili Ličnu adresu e-pošte."
|
||||
|
||||
#. Description of the 'Create Grouped Asset' (Check) field in DocType 'Item'
|
||||
#: erpnext/stock/doctype/item/item.json
|
||||
@@ -18895,44 +18895,44 @@ msgstr "Hitni Telefon"
|
||||
#: erpnext/stock/doctype/serial_no/serial_no.json
|
||||
#: erpnext/telephony/doctype/call_log/call_log.json
|
||||
msgid "Employee"
|
||||
msgstr "Personal"
|
||||
msgstr "Osoblje"
|
||||
|
||||
#. Label of the employee_link (Link) field in DocType 'Supplier Scorecard
|
||||
#. Scoring Standing'
|
||||
#: erpnext/buying/doctype/supplier_scorecard_scoring_standing/supplier_scorecard_scoring_standing.json
|
||||
msgid "Employee "
|
||||
msgstr "Personal "
|
||||
msgstr "Osoblje "
|
||||
|
||||
#. Option for the 'Reference Type' (Select) field in DocType 'Journal Entry
|
||||
#. Account'
|
||||
#: erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json
|
||||
msgid "Employee Advance"
|
||||
msgstr "Predujam Personala"
|
||||
msgstr "Predujam Osoblja"
|
||||
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:26
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:37
|
||||
msgid "Employee Advances"
|
||||
msgstr "Predujam Personala"
|
||||
msgstr "Predujam Osoblja"
|
||||
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:188
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:327
|
||||
msgid "Employee Benefits Obligation"
|
||||
msgstr "Obaveza Beneficija Personala"
|
||||
msgstr "Obaveza Pogodnosti Osoblja"
|
||||
|
||||
#. Label of the employee_detail (Section Break) field in DocType 'Timesheet'
|
||||
#: erpnext/projects/doctype/timesheet/timesheet.json
|
||||
msgid "Employee Detail"
|
||||
msgstr "Detalji Personala"
|
||||
msgstr "Detalji Osoblja"
|
||||
|
||||
#. Name of a DocType
|
||||
#: erpnext/setup/doctype/employee_education/employee_education.json
|
||||
msgid "Employee Education"
|
||||
msgstr "Obuka Personala"
|
||||
msgstr "Obuka Osoblja"
|
||||
|
||||
#. Name of a DocType
|
||||
#: erpnext/setup/doctype/employee_external_work_history/employee_external_work_history.json
|
||||
msgid "Employee External Work History"
|
||||
msgstr "Eksterna Radna Historija Personala"
|
||||
msgstr "Vanjska Radna Historija Osoblja"
|
||||
|
||||
#. Label of the employee_group (Link) field in DocType 'Communication Medium
|
||||
#. Timeslot'
|
||||
@@ -18940,12 +18940,12 @@ msgstr "Eksterna Radna Historija Personala"
|
||||
#: erpnext/communication/doctype/communication_medium_timeslot/communication_medium_timeslot.json
|
||||
#: erpnext/setup/doctype/employee_group/employee_group.json
|
||||
msgid "Employee Group"
|
||||
msgstr "Grupa Personala"
|
||||
msgstr "Grupa Osoblja"
|
||||
|
||||
#. Name of a DocType
|
||||
#: erpnext/setup/doctype/employee_group_table/employee_group_table.json
|
||||
msgid "Employee Group Table"
|
||||
msgstr "Tabela Grupe Personala"
|
||||
msgstr "Tabela Grupe Osoblja"
|
||||
|
||||
#: erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py:33
|
||||
msgid "Employee ID"
|
||||
@@ -18954,7 +18954,7 @@ msgstr "ID Personala"
|
||||
#. Name of a DocType
|
||||
#: erpnext/setup/doctype/employee_internal_work_history/employee_internal_work_history.json
|
||||
msgid "Employee Internal Work History"
|
||||
msgstr "Eksterna Radna Historija Personala"
|
||||
msgstr "Unutarnja Radna Historija Osoblja"
|
||||
|
||||
#. Label of the employee_name (Data) field in DocType 'Activity Cost'
|
||||
#. Label of the employee_name (Data) field in DocType 'Timesheet'
|
||||
@@ -18965,50 +18965,50 @@ msgstr "Eksterna Radna Historija Personala"
|
||||
#: erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py:53
|
||||
#: erpnext/setup/doctype/employee_group_table/employee_group_table.json
|
||||
msgid "Employee Name"
|
||||
msgstr "Ime Personala"
|
||||
msgstr "Ime Osoblja"
|
||||
|
||||
#. Label of the employee_number (Data) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Employee Number"
|
||||
msgstr "Broj Personala"
|
||||
msgstr "Broj Osoblja"
|
||||
|
||||
#. Label of the employee_user_id (Link) field in DocType 'Call Log'
|
||||
#: erpnext/telephony/doctype/call_log/call_log.json
|
||||
msgid "Employee User Id"
|
||||
msgstr "Korisnički ID Personala"
|
||||
msgstr "Korisnički ID Osoblja"
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.py:333
|
||||
msgid "Employee cannot report to himself."
|
||||
msgstr "Personal ne može da izvještava sam sebe."
|
||||
msgstr "Osoblje ne može da izvještava samo sebe."
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.py:583
|
||||
msgid "Employee is required"
|
||||
msgstr "Potreban je Personal"
|
||||
msgstr "Osoblje je obavezno"
|
||||
|
||||
#: erpnext/assets/doctype/asset_movement/asset_movement.py:109
|
||||
msgid "Employee is required while issuing Asset {0}"
|
||||
msgstr "Personal je obavezan prilikom izdavanja Imovine {0}"
|
||||
msgstr "Osoblje je obavezno prilikom izdavanja Imovine {0}"
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.py:440
|
||||
msgid "Employee {0} already has a linked user"
|
||||
msgstr "Personal {0} već ima povezanog korisnika"
|
||||
msgstr "Osoblje {0} već ima povezanog korisnika"
|
||||
|
||||
#: erpnext/assets/doctype/asset_movement/asset_movement.py:92
|
||||
#: erpnext/assets/doctype/asset_movement/asset_movement.py:113
|
||||
msgid "Employee {0} does not belong to the company {1}"
|
||||
msgstr "Personal {0} ne pripada {1}"
|
||||
msgstr "Osoblje {0} ne pripada {1}"
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:411
|
||||
msgid "Employee {0} is currently working on another workstation. Please assign another employee."
|
||||
msgstr "{0} trenutno radi na drugoj radnoj stanici. Dodijeli drugi personal."
|
||||
msgstr "{0} trenutno radi na drugoj radnoj stanici. Dodijeli drugo osoblje."
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.py:608
|
||||
msgid "Employee {0} not found"
|
||||
msgstr "Personal {0} nije pronađen"
|
||||
msgstr "Osoblje {0} nije pronađeno"
|
||||
|
||||
#: erpnext/public/js/shop_floor/shop_floor.js:684
|
||||
msgid "Employees"
|
||||
msgstr "Personal"
|
||||
msgstr "Osoblje"
|
||||
|
||||
#: erpnext/stock/doctype/batch/batch_list.js:16
|
||||
msgid "Empty"
|
||||
@@ -21779,11 +21779,11 @@ msgstr "Od Datuma Dospijeća"
|
||||
#. Label of the from_employee (Link) field in DocType 'Asset Movement Item'
|
||||
#: erpnext/assets/doctype/asset_movement_item/asset_movement_item.json
|
||||
msgid "From Employee"
|
||||
msgstr "Od Personala"
|
||||
msgstr "Od Osoblja"
|
||||
|
||||
#: erpnext/assets/doctype/asset_movement/asset_movement.py:98
|
||||
msgid "From Employee is required while issuing Asset {0}"
|
||||
msgstr "Personal je obavezan prilikom izdavanja Imovine {0}"
|
||||
msgstr "Osoblje je obavezano prilikom izdavanja Imovine {0}"
|
||||
|
||||
#. Label of the from_external_ecomm_platform (Check) field in DocType 'Coupon
|
||||
#. Code'
|
||||
@@ -23088,7 +23088,7 @@ msgstr "Hand"
|
||||
|
||||
#: erpnext/accounts/report/accounts_payable/accounts_payable.js:161
|
||||
msgid "Handle Employee Advances"
|
||||
msgstr "Rukovanje Predujmom Personala"
|
||||
msgstr "Rukovanje Predujmom Osoblja"
|
||||
|
||||
#: erpnext/setup/setup_wizard/operations/install_fixtures.py:228
|
||||
msgid "Hardware"
|
||||
@@ -24169,7 +24169,7 @@ msgstr "Zanemari Šablon Standard Uslova Plaćanja"
|
||||
#. Settings'
|
||||
#: erpnext/projects/doctype/projects_settings/projects_settings.json
|
||||
msgid "Ignore Employee Time Overlap"
|
||||
msgstr "Zanemari preklapanje vremena Personala"
|
||||
msgstr "Zanemari preklapanje vremena Osoblja"
|
||||
|
||||
#: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js:145
|
||||
msgid "Ignore Empty Stock"
|
||||
@@ -24303,7 +24303,7 @@ msgstr "Uvoz Podataka"
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee_list.js:16
|
||||
msgid "Import Employees"
|
||||
msgstr "Uvoz Personala"
|
||||
msgstr "Uvezi Osoblje"
|
||||
|
||||
#: erpnext/edi/doctype/code_list/code_list.js:7
|
||||
#: erpnext/edi/doctype/code_list/code_list_list.js:3
|
||||
@@ -32013,7 +32013,7 @@ msgstr "N/A"
|
||||
#. Person'
|
||||
#: erpnext/setup/doctype/sales_person/sales_person.json
|
||||
msgid "Name and Employee ID"
|
||||
msgstr "Ime i Personalni ID"
|
||||
msgstr "Ime i ID Osoblja"
|
||||
|
||||
#. Label of the name_of_beneficiary (Data) field in DocType 'Bank Guarantee'
|
||||
#: erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json
|
||||
@@ -32922,7 +32922,7 @@ msgstr "Nije pronađena e-pošta za {0} {1}"
|
||||
|
||||
#: erpnext/telephony/doctype/call_log/call_log.py:119
|
||||
msgid "No employee was scheduled for call popup"
|
||||
msgstr "Personal nije zakazao poziv"
|
||||
msgstr "Osoblje nije zakazalo poziv"
|
||||
|
||||
#: banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx:235
|
||||
#: banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx:225
|
||||
@@ -32993,7 +32993,7 @@ msgstr "Broj Dokumenata"
|
||||
#: erpnext/crm/doctype/lead/lead.json
|
||||
#: erpnext/crm/doctype/opportunity/opportunity.json
|
||||
msgid "No of Employees"
|
||||
msgstr "Personalni Broj"
|
||||
msgstr "Broj Osoblja"
|
||||
|
||||
#: erpnext/crm/report/lead_conversion_time/lead_conversion_time.py:62
|
||||
msgid "No of Interactions"
|
||||
@@ -33210,7 +33210,7 @@ msgstr "Nije pronađen {0} za transakcije među poduzećima."
|
||||
#. Label of the no_of_employees (Select) field in DocType 'Prospect'
|
||||
#: erpnext/crm/doctype/prospect/prospect.json
|
||||
msgid "No. of Employees"
|
||||
msgstr "Personalni Broj"
|
||||
msgstr "Broj Osoblja"
|
||||
|
||||
#: erpnext/manufacturing/doctype/workstation/workstation.js:63
|
||||
msgid "No. of parallel job cards which can be allowed on this workstation. Example: 2 would mean this workstation can process production for two Work Orders at a time."
|
||||
@@ -33477,7 +33477,7 @@ msgstr "Obavijesti klijente putem e-pošte"
|
||||
#: erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.json
|
||||
#: erpnext/buying/doctype/supplier_scorecard_scoring_standing/supplier_scorecard_scoring_standing.json
|
||||
msgid "Notify Employee"
|
||||
msgstr "Obavijesti Personal"
|
||||
msgstr "Obavijesti Osoblje"
|
||||
|
||||
#. Label of the notify_employee (Check) field in DocType 'Supplier Scorecard
|
||||
#. Standing'
|
||||
@@ -37674,7 +37674,7 @@ msgstr "Lični Detalji"
|
||||
#. Label of the personal_email (Data) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
msgid "Personal Email"
|
||||
msgstr "Liöna e-pošta"
|
||||
msgstr "Lična adresa e-pošte"
|
||||
|
||||
#: erpnext/setup/setup_wizard/setup_wizard.py:33
|
||||
msgid "Personalizing your setup"
|
||||
@@ -37874,7 +37874,7 @@ msgstr "Quart Liquid (US)"
|
||||
|
||||
#: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.js:8
|
||||
msgid "Pipeline By"
|
||||
msgstr "Lijevak prema"
|
||||
msgstr "Proces Prema"
|
||||
|
||||
#. Label of the place_of_issue (Data) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
@@ -38347,7 +38347,7 @@ msgstr "Unesi Datum Dostave"
|
||||
|
||||
#: erpnext/setup/doctype/sales_person/sales_person_tree.js:9
|
||||
msgid "Please enter Employee Id of this sales person"
|
||||
msgstr "Unesi Personal Id ovog Prodavača"
|
||||
msgstr "Unesi Osobni ID ovog Prodavača"
|
||||
|
||||
#: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1103
|
||||
msgid "Please enter Expense Account"
|
||||
@@ -48526,7 +48526,7 @@ msgstr "Sažetak Transakcije Prodaje po Prodavaču"
|
||||
#: erpnext/selling/page/sales_funnel/sales_funnel.js:50
|
||||
#: erpnext/workspace_sidebar/crm.json
|
||||
msgid "Sales Pipeline"
|
||||
msgstr "Prodajni Cjevovod"
|
||||
msgstr "Prodajni Proces"
|
||||
|
||||
#. Name of a report
|
||||
#. Label of a Link in the CRM Workspace
|
||||
@@ -48534,11 +48534,11 @@ msgstr "Prodajni Cjevovod"
|
||||
#: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.json
|
||||
#: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json
|
||||
msgid "Sales Pipeline Analytics"
|
||||
msgstr "Analiza Prodaje"
|
||||
msgstr "Analiza Procesa Prodaje"
|
||||
|
||||
#: erpnext/selling/page/sales_funnel/sales_funnel.js:157
|
||||
msgid "Sales Pipeline by Stage"
|
||||
msgstr "Prodaja po Fazama"
|
||||
msgstr "Proces Prodaje po Fazama"
|
||||
|
||||
#: erpnext/stock/report/item_prices/item_prices.py:58
|
||||
msgid "Sales Price List"
|
||||
@@ -49230,7 +49230,7 @@ msgstr "Odaberite Klijente po"
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.js:244
|
||||
msgid "Select Date of Birth. This will validate Employees age and prevent hiring of under-age staff."
|
||||
msgstr "Navedi Datum Rođenja. Ovo će potvrditi dob personala i spriječiti zapošljavanje maloljetnih osoba."
|
||||
msgstr "Navedi Datum Rođenja. Ovo će potvrditi dob Osoblja i spriječiti zapošljavanje maloljetnih osoba."
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.js:251
|
||||
msgid "Select Date of joining. It will have impact on the first salary calculation, Leave allocation on pro-rata bases."
|
||||
@@ -49256,7 +49256,7 @@ msgstr "Odaberi Otpremnu Adresu "
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.js:705
|
||||
msgid "Select Employees"
|
||||
msgstr "Navedi Personal"
|
||||
msgstr "Odaberi Osoblje"
|
||||
|
||||
#: erpnext/buying/doctype/purchase_order/purchase_order.js:174
|
||||
#: erpnext/selling/doctype/sales_order/sales_order.js:862
|
||||
@@ -49378,7 +49378,7 @@ msgstr "Odaberi Poduzeće"
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.js:239
|
||||
msgid "Select a Company this Employee belongs to."
|
||||
msgstr "Navedi Poduzeće kojoj ovaj personal pripada."
|
||||
msgstr "Odaberi Poduzeće kojoj ovo Osoblje pripada."
|
||||
|
||||
#: erpnext/buying/doctype/supplier/supplier.js:221
|
||||
msgid "Select a Customer"
|
||||
@@ -50788,7 +50788,7 @@ msgstr "Postavljanje Tipa Računa pomaže pri odabiru Računa u transakcijama."
|
||||
|
||||
#: erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py:129
|
||||
msgid "Setting Events to {0}, since the Employee attached to the below Sales Persons does not have a User ID{1}"
|
||||
msgstr "Postavljanje Događaja na {0}, budući da Personal vezan za ispod navedene Prodavače nema Korisnički ID{1}"
|
||||
msgstr "Postavljanje Događaja na {0}, budući da Osoblje vezano za ispod navedene Prodavače nema Korisnički ID {1}"
|
||||
|
||||
#: erpnext/stock/doctype/pick_list/pick_list.js:98
|
||||
msgid "Setting Item Locations..."
|
||||
@@ -55984,7 +55984,7 @@ msgstr "Sljedeći izbrisani atributi postoje u varijantama, ali ne i u šablonu.
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.py:286
|
||||
msgid "The following employees are currently still reporting to {0}:"
|
||||
msgstr "Sljedeći personal još uvijek podnose izvještaj {0}:"
|
||||
msgstr "Sljedeće Osoblje još uvijek podnosi izvještaj {0}:"
|
||||
|
||||
#: erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py:185
|
||||
msgid "The following invalid Pricing Rules are deleted:{0}"
|
||||
@@ -57117,7 +57117,7 @@ msgstr "Do Datuma isteka roka"
|
||||
#. Label of the to_employee (Link) field in DocType 'Asset Movement Item'
|
||||
#: erpnext/assets/doctype/asset_movement_item/asset_movement_item.json
|
||||
msgid "To Employee"
|
||||
msgstr "Za Personal"
|
||||
msgstr "Za Osoblje"
|
||||
|
||||
#. Label of the to_fiscal_year (Link) field in DocType 'Budget'
|
||||
#: erpnext/accounts/doctype/budget/budget.json
|
||||
@@ -60113,11 +60113,11 @@ msgstr "Korisnik {0} je onemogućen. Odaberi važećeg korisnika/blagajnika"
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.py:365
|
||||
msgid "User {0}: Removed Employee Self Service role as there is no mapped employee."
|
||||
msgstr "Korisnik {0}: Uklonjena uloga samoposluživanja zaposlenika jer nema mapiranog zaposlenika."
|
||||
msgstr "Korisnik {0}: Uklonjena uloga samoposluživanja Osoblja jer nema mapiranog Osoblja."
|
||||
|
||||
#: erpnext/setup/doctype/employee/employee.py:360
|
||||
msgid "User {0}: Removed Employee role as there is no mapped employee."
|
||||
msgstr "Korisnik {0}: Uklonjena uloga personala jer nema mapiranog personala."
|
||||
msgstr "Korisnik {0}: Uklonjena uloga Osoblja jer nema mapiranog Osoblja."
|
||||
|
||||
#. Description of the 'Set Landed Cost Based on Purchase Invoice Rate' (Check)
|
||||
#. field in DocType 'Buying Settings'
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: hello@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-07-05 10:19+0000\n"
|
||||
"PO-Revision-Date: 2026-07-06 21:26\n"
|
||||
"PO-Revision-Date: 2026-07-09 21:42\n"
|
||||
"Last-Translator: hello@frappe.io\n"
|
||||
"Language-Team: Swedish\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -34582,12 +34582,12 @@ msgstr "Möjlighet Källa"
|
||||
#. Label of a Workspace Sidebar Item
|
||||
#: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json
|
||||
msgid "Opportunity Summary by Sales Stage"
|
||||
msgstr "Möjlighet Översikt efter Försäljning Fas"
|
||||
msgstr "Möjlighet Översikt efter Försäljning Steg"
|
||||
|
||||
#. Name of a report
|
||||
#: erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.json
|
||||
msgid "Opportunity Summary by Sales Stage "
|
||||
msgstr "Möjlighet Översikt efter Försäljning Fas "
|
||||
msgstr "Möjlighet Översikt efter Försäljning Steg "
|
||||
|
||||
#. Label of the opportunity_type (Link) field in DocType 'Opportunity'
|
||||
#. Name of a DocType
|
||||
@@ -37880,7 +37880,7 @@ msgstr "Pint, Liquid (US)"
|
||||
|
||||
#: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.js:8
|
||||
msgid "Pipeline By"
|
||||
msgstr "Tratt Efter"
|
||||
msgstr "Process Efter"
|
||||
|
||||
#. Label of the place_of_issue (Data) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
@@ -48541,11 +48541,11 @@ msgstr "Försäljning"
|
||||
#: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.json
|
||||
#: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json
|
||||
msgid "Sales Pipeline Analytics"
|
||||
msgstr "Försäljning Statistik"
|
||||
msgstr "Försäljning Process Statistik"
|
||||
|
||||
#: erpnext/selling/page/sales_funnel/sales_funnel.js:157
|
||||
msgid "Sales Pipeline by Stage"
|
||||
msgstr "Försäljning efter Fas"
|
||||
msgstr "Försäljning Process efter Steg"
|
||||
|
||||
#: erpnext/stock/report/item_prices/item_prices.py:58
|
||||
msgid "Sales Price List"
|
||||
@@ -48578,7 +48578,7 @@ msgstr "Försäljning Retur"
|
||||
#: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py:69
|
||||
#: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json
|
||||
msgid "Sales Stage"
|
||||
msgstr "Försäljning Fas"
|
||||
msgstr "Försäljning Steg"
|
||||
|
||||
#: erpnext/accounts/doctype/pos_closing_entry/closing_voucher_details.html:8
|
||||
msgid "Sales Summary"
|
||||
@@ -52005,7 +52005,7 @@ msgstr "Kvadratyard"
|
||||
#. Label of the stage_name (Data) field in DocType 'Sales Stage'
|
||||
#: erpnext/crm/doctype/sales_stage/sales_stage.json
|
||||
msgid "Stage Name"
|
||||
msgstr "Fas Namn"
|
||||
msgstr "Försäljning Steg Namn"
|
||||
|
||||
#. Label of the stale_days (Int) field in DocType 'Accounts Settings'
|
||||
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
|
||||
@@ -56228,11 +56228,11 @@ msgstr "System kommer att skapa Försäljning Faktura eller Kassa Faktura från
|
||||
|
||||
#: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1239
|
||||
msgid "The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Draft stage"
|
||||
msgstr "Uppgift är i kö som bakgrund jobb. Om det finns problem med behandling i bakgrund kommer system att lägga till kommentar om fel i denna Lager Inventering och återgå till Utkast status."
|
||||
msgstr "Uppgift är i kö som bakgrund jobb. Om det finns problem med behandling i bakgrund kommer system att lägga till kommentar om fel i denna Lager Inventering och återgå till Utkast steg"
|
||||
|
||||
#: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1250
|
||||
msgid "The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Submitted stage"
|
||||
msgstr "Uppgift är i kö som ett bakgrund jobb. Om det finns några problem med bearbetning i bakgrund kommer system att lägga till kommentar om fel på denna Lager Inventering och återgå till Godkänd status"
|
||||
msgstr "Uppgift är i kö som ett bakgrund jobb. Om det finns några problem med bearbetning i bakgrund kommer system att lägga till kommentar om fel på denna Lager Inventering och återgå till Godkänd steg"
|
||||
|
||||
#: erpnext/stock/doctype/material_request/material_request.py:352
|
||||
msgid "The total Issue / Transfer quantity {0} in Material Request {1} cannot be greater than allowed requested quantity {2} for Item {3}"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -318,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"))
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.report.quotation_trends.quotation_trends import execute
|
||||
@@ -86,3 +87,94 @@ class TestQuotationTrends(ERPNextTestSuite):
|
||||
|
||||
labels, after = self.run_report(based_on="Customer")
|
||||
self.assertEqual(self._cell(after, "Party", "_Test Customer", amt_col, labels) - before_amt, 300)
|
||||
|
||||
def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self):
|
||||
# _Test Item is quoted to two customers -> two detail rows under one header row.
|
||||
# _Test Item 2 is quoted to only one customer -> exactly one detail row under its
|
||||
# header row. A regression that double-counts header rows would inflate the chart
|
||||
# above 800; a regression that zeroes single-group rows would report less than 800.
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": FISCAL_YEAR,
|
||||
"period": "Yearly",
|
||||
"based_on": "Item",
|
||||
"group_by": "Customer",
|
||||
}
|
||||
)
|
||||
|
||||
make_quotation(
|
||||
item="_Test Item", party_name="_Test Customer", qty=4, rate=100, transaction_date=TXN_DATE
|
||||
)
|
||||
make_quotation(
|
||||
item="_Test Item", party_name="_Test Customer 1", qty=1, rate=100, transaction_date=TXN_DATE
|
||||
)
|
||||
make_quotation(
|
||||
item="_Test Item 2", party_name="_Test Customer", qty=3, rate=100, transaction_date=TXN_DATE
|
||||
)
|
||||
|
||||
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"])
|
||||
|
||||
# 400 (item/customer) + 100 (item/customer1) + 300 (item2/customer) = 800
|
||||
self.assertEqual(expected_total, 800)
|
||||
self.assertEqual(chart_total, expected_total)
|
||||
|
||||
def test_group_by_swapped_roles_based_on_customer_group_by_item(self):
|
||||
# Same regression, opposite role assignment: based_on="Customer" with group_by="Item".
|
||||
# Customer's based_on_cols for Quotation (Party, Party Name, Territory, Currency) put
|
||||
# the group_by placeholder at a different column index than the Item-based_on case
|
||||
# above, exercising the alternate `inc`/`ind` arithmetic.
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": FISCAL_YEAR,
|
||||
"period": "Yearly",
|
||||
"based_on": "Customer",
|
||||
"group_by": "Item",
|
||||
}
|
||||
)
|
||||
|
||||
make_quotation(
|
||||
party_name="_Test Customer", item="_Test Item", qty=3, rate=100, transaction_date=TXN_DATE
|
||||
)
|
||||
make_quotation(
|
||||
party_name="_Test Customer", item="_Test Item 2", qty=1, rate=100, transaction_date=TXN_DATE
|
||||
)
|
||||
|
||||
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.
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": FISCAL_YEAR,
|
||||
"period": "Yearly",
|
||||
"based_on": "Item",
|
||||
"group_by": "Customer",
|
||||
}
|
||||
)
|
||||
|
||||
make_quotation(
|
||||
item="_Test Item", party_name="_Test Customer", qty=2, rate=150, transaction_date=TXN_DATE
|
||||
)
|
||||
|
||||
columns, data, _message, chart = execute(filters)
|
||||
chart_total = sum(chart["data"]["datasets"][0]["values"])
|
||||
|
||||
self.assertGreater(chart_total, 0)
|
||||
self.assertEqual(chart_total, 300)
|
||||
|
||||
@@ -39,9 +39,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]]
|
||||
@@ -58,4 +64,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
|
||||
|
||||
|
||||
@@ -51,3 +54,160 @@ class TestSalesOrderTrends(ERPNextTestSuite):
|
||||
self.assertTrue(columns)
|
||||
customer_rows = [row for row in data if row[0] == "_Test Customer"]
|
||||
self.assertEqual(len(customer_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 (an SO of qty=3, rate=100 -> 300 read as 600).
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_order_trends.sales_order_trends import execute
|
||||
|
||||
make_sales_order(item_code="_Test Item", 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)
|
||||
|
||||
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.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_order_trends.sales_order_trends import execute
|
||||
|
||||
make_sales_order(item_code="_Test Item", 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 customers -> two detail rows under one header row.
|
||||
# _Test Item 2 has only one customer -> 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.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_order_trends.sales_order_trends import execute
|
||||
|
||||
make_sales_order(
|
||||
item_code="_Test Item", customer="_Test Customer", qty=3, rate=100, transaction_date=today()
|
||||
)
|
||||
make_sales_order(
|
||||
item_code="_Test Item", customer="_Test Customer 1", qty=2, rate=100, transaction_date=today()
|
||||
)
|
||||
make_sales_order(
|
||||
item_code="_Test Item 2", customer="_Test Customer", 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": "Customer",
|
||||
}
|
||||
)
|
||||
|
||||
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/customer) + 200 (item/customer1) + 100 (item2/customer) = 600
|
||||
self.assertEqual(expected_total, 600)
|
||||
self.assertEqual(chart_total, expected_total)
|
||||
|
||||
def test_group_by_swapped_roles_based_on_customer_group_by_item(self):
|
||||
# Same regression, opposite role assignment: based_on="Customer" with group_by="Item".
|
||||
# Customer's based_on_cols (Customer, Customer Name, Territory, 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.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_order_trends.sales_order_trends import execute
|
||||
|
||||
make_sales_order(
|
||||
item_code="_Test Item", customer="_Test Customer", qty=3, rate=100, transaction_date=today()
|
||||
)
|
||||
make_sales_order(
|
||||
item_code="_Test Item 2", customer="_Test Customer", 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": "Customer",
|
||||
"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.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_order_trends.sales_order_trends import execute
|
||||
|
||||
make_sales_order(
|
||||
item_code="_Test Item", customer="_Test Customer", 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": "Customer",
|
||||
}
|
||||
)
|
||||
|
||||
columns, data, _message, chart = execute(filters)
|
||||
chart_total = sum(chart["data"]["datasets"][0]["values"])
|
||||
|
||||
self.assertGreater(chart_total, 0)
|
||||
self.assertEqual(chart_total, 300)
|
||||
|
||||
@@ -401,22 +401,34 @@ class DeliveryNote(SellingController):
|
||||
frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"]))
|
||||
|
||||
def update_current_stock(self):
|
||||
if self.get("_action") and self._action != "update_after_submit":
|
||||
for d in self.get("items"):
|
||||
d.actual_qty = frappe.db.get_value(
|
||||
"Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty"
|
||||
)
|
||||
if not (self.get("_action") and self._action != "update_after_submit"):
|
||||
return
|
||||
|
||||
for d in self.get("packed_items"):
|
||||
bin_qty = frappe.db.get_value(
|
||||
"Bin",
|
||||
{"item_code": d.item_code, "warehouse": d.warehouse},
|
||||
["actual_qty", "projected_qty"],
|
||||
as_dict=True,
|
||||
)
|
||||
if bin_qty:
|
||||
d.actual_qty = flt(bin_qty.actual_qty)
|
||||
d.projected_qty = flt(bin_qty.projected_qty)
|
||||
warehouse_item_codes = {}
|
||||
for d in self.get("items") + self.get("packed_items"):
|
||||
warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code)
|
||||
|
||||
if not warehouse_item_codes:
|
||||
return
|
||||
|
||||
bin_map = {}
|
||||
for warehouse, item_codes in warehouse_item_codes.items():
|
||||
for b in frappe.get_all(
|
||||
"Bin",
|
||||
filters={"item_code": ["in", item_codes], "warehouse": warehouse},
|
||||
fields=["item_code", "actual_qty", "projected_qty"],
|
||||
):
|
||||
bin_map[(b.item_code, warehouse)] = b
|
||||
|
||||
for d in self.get("items"):
|
||||
bin_data = bin_map.get((d.item_code, d.warehouse))
|
||||
d.actual_qty = bin_data.actual_qty if bin_data else None
|
||||
|
||||
for d in self.get("packed_items"):
|
||||
bin_data = bin_map.get((d.item_code, d.warehouse))
|
||||
if bin_data:
|
||||
d.actual_qty = flt(bin_data.actual_qty)
|
||||
d.projected_qty = flt(bin_data.projected_qty)
|
||||
|
||||
def validate_expense_account(self):
|
||||
company_values = frappe.get_cached_value(
|
||||
|
||||
@@ -54,6 +54,10 @@ frappe.ui.form.on("Item", {
|
||||
}
|
||||
},
|
||||
|
||||
allow_negative_stock(frm) {
|
||||
erpnext.utils.confirm_negative_stock(frm);
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
frm.add_fetch("attribute", "numeric_values", "numeric_values");
|
||||
frm.add_fetch("attribute", "from_range", "from_range");
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
"ignore_user_permissions": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Variant Of",
|
||||
"link_filters": "[[\"Item\",\"has_variants\",\"=\",1]]",
|
||||
"options": "Item",
|
||||
"read_only": 1,
|
||||
"search_index": 1,
|
||||
@@ -1090,7 +1091,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-06-26 10:05:00.000000",
|
||||
"modified": "2026-07-05 23:24:45.734144",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -506,6 +506,100 @@ class TestItem(ERPNextTestSuite):
|
||||
"Large",
|
||||
)
|
||||
|
||||
def test_rename_attribute_abbr_updates_variant_item_code(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1)
|
||||
|
||||
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
|
||||
variant.save()
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "LRG"
|
||||
break
|
||||
|
||||
def restore_test_size_abbr():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "L"
|
||||
break
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_abbr)
|
||||
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1))
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item-L"))
|
||||
self.assertTrue(frappe.db.exists("Item", "_Test Variant Item-LRG"))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item", "_Test Variant Item-LRG", "item_name"),
|
||||
"_Test Variant Item-LRG",
|
||||
)
|
||||
|
||||
def test_rename_attribute_abbr_updates_variant_item_name_from_template_name(self):
|
||||
# item_name can be derived from the template's item_name, which may differ from its
|
||||
# item_code (e.g. a friendly display name vs. a SKU-style code). The variant's item_name
|
||||
# must follow the abbreviation rename the same way item_code does.
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-L", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1)
|
||||
|
||||
template = frappe.get_doc("Item", "_Test Variant Item").as_dict()
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "_Test Variant Item Diff",
|
||||
"item_name": "Test Variant Friendly Name",
|
||||
"item_group": template.item_group,
|
||||
"stock_uom": template.stock_uom,
|
||||
"has_variants": 1,
|
||||
"attributes": [{"attribute": "Test Size"}],
|
||||
}
|
||||
)
|
||||
template.insert()
|
||||
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1))
|
||||
|
||||
variant = create_variant("_Test Variant Item Diff", {"Test Size": "Large"})
|
||||
variant.save()
|
||||
self.assertEqual(variant.item_code, "_Test Variant Item Diff-L")
|
||||
self.assertEqual(variant.item_name, "Test Variant Friendly Name-L")
|
||||
|
||||
# even a manually customized item_name (unrelated to the auto-generated pattern) must be
|
||||
# rebuilt on abbreviation rename, since item_code and item_name are meant to stay in lockstep.
|
||||
frappe.db.set_value("Item", variant.name, "item_name", "Custom Friendly Large Shirt Name")
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "LRG"
|
||||
break
|
||||
|
||||
def restore_test_size_abbr():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "L"
|
||||
break
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_abbr)
|
||||
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1))
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item Diff-L"))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item", "_Test Variant Item Diff-LRG", "item_name"),
|
||||
"Test Variant Friendly Name-LRG",
|
||||
)
|
||||
|
||||
def test_make_item_variant(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from frappe.utils import flt
|
||||
from erpnext.controllers.item_variant import (
|
||||
InvalidItemAttributeValueError,
|
||||
update_variant_attribute_values,
|
||||
update_variant_item_codes_for_abbr_renames,
|
||||
validate_is_incremental,
|
||||
validate_item_attribute_value,
|
||||
)
|
||||
@@ -46,6 +47,7 @@ class ItemAttribute(Document):
|
||||
|
||||
def on_update(self):
|
||||
update_variant_attribute_values(self)
|
||||
update_variant_item_codes_for_abbr_renames(self)
|
||||
self.validate_exising_items()
|
||||
self.set_enabled_disabled_in_items()
|
||||
|
||||
|
||||
@@ -289,15 +289,22 @@ def create_stock_entry(pick_list: str | dict):
|
||||
stock_entry.pick_list = pick_list.get("name")
|
||||
stock_entry.purpose = pick_list.get("purpose")
|
||||
stock_entry.company = pick_list.get("company")
|
||||
stock_entry.set_stock_entry_type()
|
||||
|
||||
if pick_list.get("work_order"):
|
||||
job_card = pick_list.get("material_request") and frappe.db.get_value(
|
||||
"Material Request", pick_list.get("material_request"), "job_card"
|
||||
)
|
||||
|
||||
if job_card:
|
||||
stock_entry = update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card)
|
||||
elif pick_list.get("work_order"):
|
||||
stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry)
|
||||
elif pick_list.get("material_request"):
|
||||
stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry)
|
||||
else:
|
||||
stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry)
|
||||
|
||||
stock_entry.set_stock_entry_type()
|
||||
|
||||
if not stock_entry.get("items"):
|
||||
return frappe.msgprint(_("All picked items have already been transferred against this Pick List"))
|
||||
|
||||
@@ -344,9 +351,57 @@ def stock_entry_exists(pick_list_name):
|
||||
return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name})
|
||||
|
||||
|
||||
def update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card):
|
||||
job_card = frappe.db.get_value(
|
||||
"Job Card",
|
||||
job_card,
|
||||
["name", "work_order", "bom_no", "semi_fg_bom", "for_quantity", "transferred_qty", "wip_warehouse"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
stock_entry.purpose = "Material Transfer for Manufacture"
|
||||
stock_entry.job_card = job_card.name
|
||||
stock_entry.work_order = job_card.work_order
|
||||
stock_entry.from_bom = 1
|
||||
stock_entry.bom_no = job_card.semi_fg_bom or job_card.bom_no
|
||||
stock_entry.fg_completed_qty = max(flt(job_card.for_quantity) - flt(job_card.transferred_qty), 0)
|
||||
stock_entry.to_warehouse = job_card.wip_warehouse
|
||||
|
||||
job_card_items = get_job_card_items_by_material_request_item(pick_list)
|
||||
|
||||
for location in pick_list.locations:
|
||||
if get_pending_transfer_stock_qty(location) <= 0:
|
||||
continue
|
||||
item = frappe._dict()
|
||||
update_common_item_properties(item, location)
|
||||
item.t_warehouse = job_card.wip_warehouse
|
||||
item.job_card_item = job_card_items.get(location.material_request_item)
|
||||
stock_entry.append("items", item)
|
||||
|
||||
return stock_entry
|
||||
|
||||
|
||||
def get_job_card_items_by_material_request_item(pick_list):
|
||||
material_request_items = [
|
||||
location.material_request_item for location in pick_list.locations if location.material_request_item
|
||||
]
|
||||
if not material_request_items:
|
||||
return {}
|
||||
|
||||
return dict(
|
||||
frappe.get_all(
|
||||
"Material Request Item",
|
||||
filters={"name": ["in", material_request_items]},
|
||||
fields=["name", "job_card_item"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_stock_entry_based_on_work_order(pick_list, stock_entry):
|
||||
work_order = frappe.get_doc("Work Order", pick_list.get("work_order"))
|
||||
|
||||
stock_entry.purpose = "Material Transfer for Manufacture"
|
||||
stock_entry.work_order = work_order.name
|
||||
stock_entry.company = work_order.company
|
||||
stock_entry.from_bom = 1
|
||||
|
||||
@@ -342,7 +342,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend
|
||||
|
||||
make_retention_stock_entry() {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse",
|
||||
method: "erpnext.stock.doctype.stock_entry.services.manufacturing.move_sample_to_retention_warehouse",
|
||||
args: {
|
||||
company: cur_frm.doc.company,
|
||||
items: cur_frm.doc.items,
|
||||
@@ -455,7 +455,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
if (d.sample_quantity && d.qty) {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity",
|
||||
method: "erpnext.stock.doctype.stock_entry.services.manufacturing.validate_sample_quantity",
|
||||
args: {
|
||||
batch_no: d.batch_no,
|
||||
item_code: d.item_code,
|
||||
|
||||
@@ -518,7 +518,7 @@ frappe.ui.form.on("Stock Entry", {
|
||||
__("Expired Batches"),
|
||||
function () {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch.get_expired_batch_items",
|
||||
method: "erpnext.stock.doctype.stock_entry.services.serial_batch.get_expired_batch_items",
|
||||
freeze: true,
|
||||
callback: function (r) {
|
||||
if (!r.exc && r.message) {
|
||||
@@ -692,7 +692,7 @@ frappe.ui.form.on("Stock Entry", {
|
||||
|
||||
make_retention_stock_entry: function (frm) {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse",
|
||||
method: "erpnext.stock.doctype.stock_entry.services.manufacturing.move_sample_to_retention_warehouse",
|
||||
args: {
|
||||
company: frm.doc.company,
|
||||
items: frm.doc.items,
|
||||
@@ -961,7 +961,7 @@ frappe.ui.form.on("Stock Entry", {
|
||||
if (frm.doc.purchase_order) {
|
||||
frm.set_value("subcontracting_order", "");
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order",
|
||||
method: "erpnext.stock.doctype.stock_entry.services.subcontracting.get_items_from_subcontract_order",
|
||||
source_name: frm.doc.purchase_order,
|
||||
target_doc: frm,
|
||||
freeze: true,
|
||||
@@ -973,7 +973,7 @@ frappe.ui.form.on("Stock Entry", {
|
||||
if (frm.doc.subcontracting_order) {
|
||||
frm.set_value("purchase_order", "");
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order",
|
||||
method: "erpnext.stock.doctype.stock_entry.services.subcontracting.get_items_from_subcontract_order",
|
||||
source_name: frm.doc.subcontracting_order,
|
||||
target_doc: frm,
|
||||
freeze: true,
|
||||
@@ -1187,7 +1187,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
if (d.sample_quantity && d.transfer_qty && frm.doc.purpose == "Material Receipt") {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity",
|
||||
method: "erpnext.stock.doctype.stock_entry.services.manufacturing.validate_sample_quantity",
|
||||
args: {
|
||||
batch_no: d.batch_no,
|
||||
item_code: d.item_code,
|
||||
|
||||
@@ -1423,6 +1423,23 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
used_alternative_items = get_used_alternative_items(
|
||||
subcontract_order_field=self.subcontract_data.order_field, work_order=self.work_order
|
||||
)
|
||||
|
||||
skip_transfer, from_wip_warehouse = (
|
||||
frappe.get_value("Work Order", self.work_order, ["skip_transfer", "from_wip_warehouse"])
|
||||
if self.work_order
|
||||
else [None, None]
|
||||
)
|
||||
wo_item_source_warehouses = {}
|
||||
if skip_transfer and not from_wip_warehouse:
|
||||
for d in frappe.get_all(
|
||||
"Work Order Item",
|
||||
filters={"parent": self.work_order},
|
||||
fields=["item_code", "source_warehouse"],
|
||||
):
|
||||
# default ordering is creation desc; keep the first (most recent) row per
|
||||
# item_code to match the limit-1 behaviour of the get_value call this replaces
|
||||
wo_item_source_warehouses.setdefault(d.item_code, d.source_warehouse)
|
||||
|
||||
for item in item_dict.values():
|
||||
# if source warehouse presents in BOM set from_warehouse as bom source_warehouse
|
||||
if item["allow_alternative_item"]:
|
||||
@@ -1430,18 +1447,8 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
"Work Order", self.work_order, "allow_alternative_item"
|
||||
)
|
||||
|
||||
skip_transfer, from_wip_warehouse = (
|
||||
frappe.get_value("Work Order", self.work_order, ["skip_transfer", "from_wip_warehouse"])
|
||||
if self.work_order
|
||||
else [None, None]
|
||||
)
|
||||
|
||||
item.from_warehouse = (
|
||||
frappe.get_value(
|
||||
"Work Order Item",
|
||||
{"parent": self.work_order, "item_code": item.item_code},
|
||||
"source_warehouse",
|
||||
)
|
||||
wo_item_source_warehouses.get(item.item_code)
|
||||
if skip_transfer and not from_wip_warehouse
|
||||
else self.from_warehouse or item.source_warehouse or item.default_warehouse
|
||||
)
|
||||
|
||||
@@ -96,25 +96,7 @@ frappe.ui.form.on("Stock Settings", {
|
||||
},
|
||||
|
||||
allow_negative_stock: function (frm) {
|
||||
if (!frm.doc.allow_negative_stock) {
|
||||
return;
|
||||
}
|
||||
|
||||
let msg = __(
|
||||
"Using negative stock disables FIFO/Moving average valuation when inventory is negative."
|
||||
);
|
||||
msg += " ";
|
||||
msg += __("This is considered dangerous from accounting point of view.");
|
||||
msg += "<br>";
|
||||
msg += __("Do you still want to enable negative inventory?");
|
||||
|
||||
frappe.confirm(
|
||||
msg,
|
||||
() => {},
|
||||
() => {
|
||||
frm.set_value("allow_negative_stock", 0);
|
||||
}
|
||||
);
|
||||
erpnext.utils.confirm_negative_stock(frm);
|
||||
},
|
||||
auto_insert_price_list_rate_if_missing(frm) {
|
||||
if (!frm.doc.auto_insert_price_list_rate_if_missing) return;
|
||||
|
||||
@@ -1594,7 +1594,7 @@ def get_batch_qty(batch_no: str, warehouse: str, item_code: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
@erpnext.normalize_ctx_input(ItemDetailsCtx)
|
||||
def apply_price_list(ctx: ItemDetailsCtx, as_doc: bool = False, doc: Document | str | None = None):
|
||||
def apply_price_list(ctx: ItemDetailsCtx, as_doc: bool = False, doc: Document | str | dict | None = None):
|
||||
"""Apply pricelist on a document-like dict object and return as
|
||||
{'parent': dict, 'children': list}
|
||||
|
||||
|
||||
@@ -182,6 +182,10 @@ def get_item_warehouse_projected_qty(items_to_consider):
|
||||
item_warehouse_projected_qty = {}
|
||||
items_to_consider = list(items_to_consider.keys())
|
||||
|
||||
warehouse_parent_map = frappe._dict(
|
||||
frappe.get_all("Warehouse", fields=["name", "parent_warehouse"], as_list=True)
|
||||
)
|
||||
|
||||
for item_code, warehouse, projected_qty in frappe.get_all(
|
||||
"Bin",
|
||||
filters={"item_code": ["in", items_to_consider], "warehouse": ["is", "set"]},
|
||||
@@ -194,16 +198,14 @@ def get_item_warehouse_projected_qty(items_to_consider):
|
||||
if warehouse not in item_warehouse_projected_qty.get(item_code):
|
||||
item_warehouse_projected_qty[item_code][warehouse] = flt(projected_qty)
|
||||
|
||||
warehouse_doc = frappe.get_doc("Warehouse", warehouse)
|
||||
parent_warehouse = warehouse_parent_map.get(warehouse)
|
||||
|
||||
while warehouse_doc.parent_warehouse:
|
||||
if not item_warehouse_projected_qty.get(item_code, {}).get(warehouse_doc.parent_warehouse):
|
||||
item_warehouse_projected_qty.setdefault(item_code, {})[warehouse_doc.parent_warehouse] = flt(
|
||||
projected_qty
|
||||
)
|
||||
while parent_warehouse:
|
||||
if not item_warehouse_projected_qty.get(item_code, {}).get(parent_warehouse):
|
||||
item_warehouse_projected_qty.setdefault(item_code, {})[parent_warehouse] = flt(projected_qty)
|
||||
else:
|
||||
item_warehouse_projected_qty[item_code][warehouse_doc.parent_warehouse] += flt(projected_qty)
|
||||
warehouse_doc = frappe.get_doc("Warehouse", warehouse_doc.parent_warehouse)
|
||||
item_warehouse_projected_qty[item_code][parent_warehouse] += flt(projected_qty)
|
||||
parent_warehouse = warehouse_parent_map.get(parent_warehouse)
|
||||
|
||||
return item_warehouse_projected_qty
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ def execute(filters=None):
|
||||
conditions = get_columns(filters, "Delivery Note")
|
||||
data = get_data(filters, conditions)
|
||||
|
||||
chart_data = get_chart_data(data, filters)
|
||||
chart_data = get_chart_data(data, conditions, filters)
|
||||
|
||||
return conditions["columns"], data, None, chart_data
|
||||
|
||||
|
||||
def get_chart_data(data, filters):
|
||||
def get_chart_data(data, conditions, filters):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
@@ -52,4 +52,6 @@ def get_chart_data(data, filters):
|
||||
},
|
||||
"type": "bar",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
import erpnext
|
||||
|
||||
|
||||
def execute(filters: dict | None = None):
|
||||
columns = get_columns()
|
||||
@@ -24,6 +26,14 @@ def get_columns() -> list[dict]:
|
||||
"label": _("Total Landed Cost"),
|
||||
"fieldname": "landed_cost",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
},
|
||||
{
|
||||
"label": _("Currency"),
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"options": "Currency",
|
||||
"hidden": 1,
|
||||
},
|
||||
{
|
||||
"label": _("Purchase Voucher Type"),
|
||||
@@ -49,6 +59,7 @@ def get_columns() -> list[dict]:
|
||||
|
||||
|
||||
def get_data(filters) -> list[list]:
|
||||
company_currency = erpnext.get_company_currency(filters.get("company"))
|
||||
landed_cost_vouchers = get_landed_cost_vouchers(filters) or {}
|
||||
landed_vouchers = list(landed_cost_vouchers.keys())
|
||||
vendor_invoices = {}
|
||||
@@ -57,7 +68,6 @@ def get_data(filters) -> list[list]:
|
||||
|
||||
data = []
|
||||
|
||||
print(vendor_invoices)
|
||||
for name, vouchers in landed_cost_vouchers.items():
|
||||
res = {
|
||||
"name": name,
|
||||
@@ -72,6 +82,7 @@ def get_data(filters) -> list[list]:
|
||||
"landed_cost": d.landed_cost,
|
||||
"voucher_type": d.voucher_type,
|
||||
"voucher_no": d.voucher_no,
|
||||
"currency": company_currency,
|
||||
}
|
||||
)
|
||||
else:
|
||||
@@ -88,7 +99,6 @@ def get_data(filters) -> list[list]:
|
||||
|
||||
if vendor_invoice_list and len(vendor_invoice_list) > len(vouchers):
|
||||
for row in vendor_invoice_list[last_index + 1 :]:
|
||||
print(row)
|
||||
data.append({"vendor_invoice": row})
|
||||
|
||||
return data
|
||||
|
||||
@@ -14,12 +14,12 @@ def execute(filters=None):
|
||||
conditions = get_columns(filters, "Purchase Receipt")
|
||||
data = get_data(filters, conditions)
|
||||
|
||||
chart_data = get_chart_data(data, filters)
|
||||
chart_data = get_chart_data(data, conditions, filters)
|
||||
|
||||
return conditions["columns"], data, None, chart_data
|
||||
|
||||
|
||||
def get_chart_data(data, filters):
|
||||
def get_chart_data(data, conditions, filters):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
@@ -53,4 +53,6 @@ def get_chart_data(data, filters):
|
||||
"type": "bar",
|
||||
"colors": ["#5e64ff"],
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@ class FIFOSlots:
|
||||
self.serial_no_details = {}
|
||||
self.batch_no_details = {}
|
||||
self.batchwise_valuation_by_batch = {}
|
||||
self.valuation_method_by_item = {}
|
||||
self.filters = filters
|
||||
self.sle = sle
|
||||
|
||||
@@ -310,8 +311,9 @@ class FIFOSlots:
|
||||
|
||||
if stock_ledger_entries is None:
|
||||
# streaming path: nested queries invalidate the streaming cursor below,
|
||||
# so batchwise valuation flags must be resolved beforehand
|
||||
# so batchwise valuation flags and item valuation methods must be resolved beforehand
|
||||
self._prefetch_batchwise_valuations()
|
||||
self._prefetch_valuation_methods()
|
||||
|
||||
if frappe.db.db_type == "postgres":
|
||||
# postgres server-side cursors can't run nested queries mid-iteration; _get_stock_ledger_entries
|
||||
@@ -334,12 +336,28 @@ class FIFOSlots:
|
||||
for row in stock_ledger_entries:
|
||||
self._process_stock_ledger_entry(row, bundle_wise_serial_nos, bundle_wise_batch_nos)
|
||||
|
||||
self._recompute_moving_average_slots()
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||
self.item_details = self._aggregate_details_by_item(self.item_details)
|
||||
|
||||
return self.item_details
|
||||
|
||||
def _recompute_moving_average_slots(self) -> None:
|
||||
for item_dict in self.item_details.values():
|
||||
if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"):
|
||||
continue
|
||||
|
||||
details = item_dict["details"]
|
||||
if self._get_item_valuation_method(details.name) != "Moving Average":
|
||||
continue
|
||||
|
||||
rate = flt(details.valuation_rate)
|
||||
for slot in item_dict["fifo_queue"]:
|
||||
if is_qty_slot(slot):
|
||||
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
|
||||
|
||||
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
|
||||
if stock_ledger_entries is not None:
|
||||
return frappe._dict({}), frappe._dict({})
|
||||
@@ -360,7 +378,10 @@ class FIFOSlots:
|
||||
if row.actual_qty > 0:
|
||||
self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
|
||||
else:
|
||||
self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
|
||||
from_end = self._get_item_valuation_method(row.name) == "LIFO"
|
||||
self._compute_outgoing_stock(
|
||||
row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end
|
||||
)
|
||||
|
||||
self._update_balances(row, key)
|
||||
self._trim_serial_fifo_queue(row, key, fifo_queue)
|
||||
@@ -473,6 +494,45 @@ class FIFOSlots:
|
||||
for batch_no, use_batchwise_valuation in query.run():
|
||||
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
|
||||
|
||||
def _get_item_valuation_method(self, item_code: str) -> str:
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
if item_code not in self.valuation_method_by_item:
|
||||
# only reachable when stock ledger entries are passed in directly;
|
||||
# the streaming path prefetches all methods before iteration
|
||||
self.valuation_method_by_item[item_code] = get_valuation_method(
|
||||
item_code, self.filters.get("company")
|
||||
)
|
||||
|
||||
return self.valuation_method_by_item[item_code]
|
||||
|
||||
def _prefetch_valuation_methods(self) -> None:
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
company = self.filters.get("company")
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
item = frappe.qb.DocType("Item")
|
||||
to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sle)
|
||||
.inner_join(item)
|
||||
.on(sle.item_code == item.name)
|
||||
.select(item.name, item.valuation_method)
|
||||
.distinct()
|
||||
.where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1))
|
||||
)
|
||||
query = self._apply_filter(query, sle, "item_code")
|
||||
|
||||
# items with no item-level method share the company/settings default; resolve it once
|
||||
default_method = None
|
||||
for item_code, valuation_method in query.run():
|
||||
if not valuation_method:
|
||||
if default_method is None:
|
||||
default_method = get_valuation_method(item_code, company)
|
||||
valuation_method = default_method
|
||||
self.valuation_method_by_item[item_code] = valuation_method
|
||||
|
||||
def _init_key_stores(self, row: dict) -> tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
@@ -589,7 +649,13 @@ class FIFOSlots:
|
||||
fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference)
|
||||
|
||||
def _compute_outgoing_stock(
|
||||
self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list
|
||||
self,
|
||||
row: dict,
|
||||
fifo_queue: list,
|
||||
transfer_key: tuple,
|
||||
serial_nos: list,
|
||||
batch_nos: list,
|
||||
from_end: bool = False,
|
||||
):
|
||||
"Update FIFO Queue on outward stock."
|
||||
if serial_nos:
|
||||
@@ -597,7 +663,7 @@ class FIFOSlots:
|
||||
elif batch_nos:
|
||||
self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos)
|
||||
else:
|
||||
self._consume_fifo_slots(row, fifo_queue, transfer_key)
|
||||
self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end)
|
||||
|
||||
def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None:
|
||||
fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos]
|
||||
@@ -674,19 +740,23 @@ class FIFOSlots:
|
||||
)
|
||||
self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference])
|
||||
|
||||
def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None:
|
||||
def _consume_fifo_slots(
|
||||
self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False
|
||||
) -> None:
|
||||
# LIFO consumes the most recent inward first, so pop from the tail instead of the head.
|
||||
index = -1 if from_end else 0
|
||||
qty_to_pop = abs(row.actual_qty)
|
||||
stock_value = abs(row.stock_value_difference)
|
||||
|
||||
while qty_to_pop:
|
||||
slot = fifo_queue[0] if fifo_queue else [0, None, 0]
|
||||
slot = fifo_queue[index] if fifo_queue else [0, None, 0]
|
||||
slot_qty = flt(slot[FIFO_QTY_INDEX])
|
||||
slot_value = flt(slot[FIFO_VALUE_INDEX])
|
||||
|
||||
if 0 < slot_qty <= qty_to_pop:
|
||||
qty_to_pop -= slot_qty
|
||||
stock_value -= slot_value
|
||||
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
|
||||
self.transferred_item_details[transfer_key].append(fifo_queue.pop(index))
|
||||
elif not fifo_queue:
|
||||
fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)])
|
||||
self.transferred_item_details[transfer_key].append(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age
|
||||
@@ -63,6 +65,131 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
data = format_report_data(self.filters, slots, self.filters["to_date"])
|
||||
self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30
|
||||
|
||||
def test_moving_average_value_ties_to_stock_balance(self):
|
||||
"""For Moving Average items the queue value is re-derived as qty * rate so the
|
||||
report's stock value ties to Stock Balance, instead of stranding a residual
|
||||
from FIFO-by-qty consumption vs blended outgoing value."""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=10,
|
||||
stock_value_difference=1000,
|
||||
valuation_rate=100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=2000,
|
||||
valuation_rate=150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=10,
|
||||
stock_value_difference=(-1500),
|
||||
valuation_rate=150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=(-5),
|
||||
qty_after_transaction=5,
|
||||
stock_value_difference=(-750),
|
||||
valuation_rate=150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="004",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
]
|
||||
|
||||
with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"):
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
queue = slots["MA Item"]["fifo_queue"]
|
||||
total_value = sum(slot[2] for slot in queue)
|
||||
|
||||
# Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150
|
||||
self.assertEqual(total_value, 750.0)
|
||||
|
||||
def test_lifo_consumes_newest_first(self):
|
||||
"""LIFO items consume the most recent inward first, so the oldest lot stays on
|
||||
hand. The remaining queue, stock value and average age must reflect the older
|
||||
stock, unlike the default FIFO which retains the newest lots."""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="LIFO Item",
|
||||
actual_qty=30,
|
||||
qty_after_transaction=30,
|
||||
stock_value_difference=30,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="LIFO Item",
|
||||
actual_qty=20,
|
||||
qty_after_transaction=50,
|
||||
stock_value_difference=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="LIFO Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=40,
|
||||
stock_value_difference=(-10),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
]
|
||||
|
||||
with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"):
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
queue = slots["LIFO Item"]["fifo_queue"]
|
||||
|
||||
# newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10
|
||||
self.assertEqual(queue[0][0], 30.0)
|
||||
self.assertEqual(queue[-1][0], 10.0)
|
||||
self.assertEqual(sum(slot[0] for slot in queue), 40.0)
|
||||
self.assertEqual(sum(slot[2] for slot in queue), 40.0)
|
||||
|
||||
# average age skews older than the FIFO result (8.5) because the old lot is retained
|
||||
self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75)
|
||||
|
||||
def test_insufficient_balance(self):
|
||||
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
|
||||
sle = [
|
||||
|
||||
@@ -575,7 +575,7 @@ class TransactionBase(StatusUpdater):
|
||||
"is_internal_customer": self.is_internal_customer,
|
||||
}
|
||||
# TODO: test method call impact on document
|
||||
apply_price_list(cts=args, as_doc=True, doc=self)
|
||||
apply_price_list(ctx=args, as_doc=True, doc=self)
|
||||
|
||||
|
||||
def delete_events(ref_type, ref_name):
|
||||
|
||||
Reference in New Issue
Block a user