Merge branch 'develop' into fix-flaky-usd-exchange-rate-tests

This commit is contained in:
Nabin Hait
2026-07-11 14:29:22 +05:30
committed by GitHub
52 changed files with 1807 additions and 305 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,6 +88,7 @@ def execute(filters=None):
"parent_section": None,
"indent": 0.0,
"section": cash_flow_section["section_header"],
"currency": company_currency,
}
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'

View File

@@ -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}"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"))

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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"),
}

View File

@@ -2,7 +2,10 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import today
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
@@ -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)

View File

@@ -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(

View File

@@ -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");

View File

@@ -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",

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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;

View File

@@ -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}

View File

@@ -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

View File

@@ -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"),
}

View File

@@ -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

View File

@@ -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"),
}

View File

@@ -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(

View File

@@ -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 = [

View File

@@ -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):