mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-07 03:33:03 +00:00
Compare commits
37 Commits
mergify/bp
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1473140cc | ||
|
|
460fe9af3e | ||
|
|
9f8aa3cf1b | ||
|
|
9f9cb5c3b6 | ||
|
|
2d056aee3d | ||
|
|
e1c1c5ed7e | ||
|
|
aa70d9bbc3 | ||
|
|
626e35135f | ||
|
|
0e26f9b1db | ||
|
|
243266f5ef | ||
|
|
af3184c8b4 | ||
|
|
eeb3cd238e | ||
|
|
adfa6768c9 | ||
|
|
970a3f403d | ||
|
|
61154e22ed | ||
|
|
dc907add40 | ||
|
|
b5700831d8 | ||
|
|
a703e7a462 | ||
|
|
abc76eb49d | ||
|
|
37e96f931d | ||
|
|
a9f969e942 | ||
|
|
cd65a6d9ff | ||
|
|
ee6955d56c | ||
|
|
02f407b82a | ||
|
|
281e92fb6e | ||
|
|
285aec3164 | ||
|
|
824ae57e44 | ||
|
|
4193a441e6 | ||
|
|
b4dfca9ef1 | ||
|
|
6153202231 | ||
|
|
4babce436f | ||
|
|
aaa99f775d | ||
|
|
4ed03748fe | ||
|
|
667b012065 | ||
|
|
81e24442e3 | ||
|
|
00139081f6 | ||
|
|
a5544d0bfb |
@@ -567,7 +567,7 @@ $.extend(erpnext.journal_entry, {
|
||||
lock_reversal_entry: function (frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.filter((field) => field.df.fieldname != "posting_date")
|
||||
.filter((field) => !["posting_date", "custom_remark", "remark"].includes(field.df.fieldname))
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
@@ -2405,6 +2405,86 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_foreign_currency_reverse_payment_entry_gain_for_supplier(self):
|
||||
transaction_date = nowdate()
|
||||
self.supplier = "_Test Supplier USD"
|
||||
amount = 100
|
||||
department = frappe.db.get_value("Department", {"company": self.company, "is_group": 0}, "name")
|
||||
|
||||
# Pay USD 100 at an exchange rate of 90.
|
||||
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
|
||||
pe.payment_type = "Pay"
|
||||
pe.party_type = "Supplier"
|
||||
pe.party = self.supplier
|
||||
pe.paid_from = self.cash
|
||||
pe.paid_from_account_currency = "INR"
|
||||
pe.target_exchange_rate = 90
|
||||
pe.paid_amount = 90 * amount
|
||||
pe.received_amount = amount
|
||||
pe.paid_to = self.creditors_usd
|
||||
pe.paid_to_account_currency = "USD"
|
||||
pe.department = department
|
||||
pe = pe.save().submit()
|
||||
|
||||
# Receive USD 100 from the supplier at an exchange rate of 100.
|
||||
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
|
||||
reverse_pe.payment_type = "Receive"
|
||||
reverse_pe.party_type = "Supplier"
|
||||
reverse_pe.party = self.supplier
|
||||
reverse_pe.paid_from = self.creditors_usd
|
||||
reverse_pe.paid_from_account_currency = "USD"
|
||||
reverse_pe.source_exchange_rate = 100
|
||||
reverse_pe.paid_amount = amount
|
||||
reverse_pe.received_amount = 100 * amount
|
||||
reverse_pe.paid_to = self.cash
|
||||
reverse_pe.paid_to_account_currency = "INR"
|
||||
reverse_pe.department = department
|
||||
reverse_pe = reverse_pe.save().submit()
|
||||
|
||||
pr = self.create_payment_reconciliation(party_is_customer=False)
|
||||
pr.party = self.supplier
|
||||
pr.receivable_payable_account = self.creditors_usd
|
||||
pr.get_unreconciled_entries()
|
||||
invoices = [invoice.as_dict() for invoice in pr.invoices]
|
||||
payments = [payment.as_dict() for payment in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
for row in pr.allocation:
|
||||
row.department = department
|
||||
|
||||
self.assertEqual(flt(pr.allocation[0].difference_amount), 1000)
|
||||
pr.reconcile()
|
||||
|
||||
gain_loss_journal = frappe.db.get_value(
|
||||
"Journal Entry Account",
|
||||
{
|
||||
"reference_type": reverse_pe.doctype,
|
||||
"reference_name": reverse_pe.name,
|
||||
"party": self.supplier,
|
||||
"docstatus": 1,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
party_row = frappe.db.get_value(
|
||||
"Journal Entry Account",
|
||||
{"parent": gain_loss_journal, "party": self.supplier},
|
||||
["debit", "credit"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(flt(party_row.debit), 1000)
|
||||
self.assertEqual(flt(party_row.credit), 0)
|
||||
|
||||
party_gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_no": ["in", [pe.name, reverse_pe.name, gain_loss_journal]],
|
||||
"account": self.creditors_usd,
|
||||
"party": self.supplier,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fields=["debit", "credit"],
|
||||
)
|
||||
self.assertEqual(flt(sum(row.debit - row.credit for row in party_gl_entries)), 0)
|
||||
|
||||
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
|
||||
transaction_date = nowdate()
|
||||
customer = self.customer_usd
|
||||
|
||||
@@ -6,9 +6,10 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, flt, formatdate, getdate
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
|
||||
|
||||
from erpnext import is_perpetual_inventory_enabled
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
make_closing_entries,
|
||||
)
|
||||
@@ -18,6 +19,8 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
|
||||
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import apply_unscoped_filters
|
||||
from erpnext.stock.utils import get_stock_value_on
|
||||
|
||||
|
||||
class PeriodClosingVoucher(AccountsController):
|
||||
@@ -139,6 +142,121 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if account_currency != company_currency:
|
||||
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
|
||||
|
||||
def before_submit(self):
|
||||
if not self.has_stock_transactions():
|
||||
return
|
||||
|
||||
self.validate_stock_accounts_balance()
|
||||
self.validate_stock_closing_entry()
|
||||
|
||||
def has_stock_transactions(self):
|
||||
if not is_perpetual_inventory_enabled(self.company):
|
||||
return False
|
||||
|
||||
return bool(
|
||||
frappe.db.exists(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"company": self.company,
|
||||
"is_cancelled": 0,
|
||||
"posting_date": ("<=", self.period_end_date),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def validate_stock_accounts_balance(self):
|
||||
precision = frappe.get_precision("GL Entry", "debit")
|
||||
account_balance = flt(self.get_stock_accounts_balance(), precision)
|
||||
stock_value = flt(
|
||||
get_stock_value_on(posting_date=self.period_end_date, company=self.company), precision
|
||||
)
|
||||
|
||||
if account_balance == stock_value:
|
||||
return
|
||||
|
||||
currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
frappe.throw(
|
||||
_(
|
||||
"The closing balance {0} of the Stock Asset accounts does not match the closing value {1} of the Stock Balance report as on {2}. Resolve the difference using the Stock Ledger Variance report before closing the period."
|
||||
).format(
|
||||
frappe.bold(fmt_money(account_balance, currency=currency)),
|
||||
frappe.bold(fmt_money(stock_value, currency=currency)),
|
||||
frappe.bold(formatdate(self.period_end_date)),
|
||||
),
|
||||
title=_("Stock Value Mismatch"),
|
||||
)
|
||||
|
||||
def get_stock_accounts_balance(self):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
account = frappe.qb.DocType("Account")
|
||||
|
||||
stock_accounts = (
|
||||
frappe.qb.from_(account)
|
||||
.select(account.name)
|
||||
.where(
|
||||
(account.account_type == "Stock")
|
||||
& (account.company == self.company)
|
||||
& (account.is_group == 0)
|
||||
)
|
||||
)
|
||||
|
||||
balance = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit - gle.credit))
|
||||
.where(
|
||||
(gle.company == self.company)
|
||||
& (gle.is_cancelled == 0)
|
||||
& (gle.posting_date <= self.period_end_date)
|
||||
& gle.account.isin(stock_accounts)
|
||||
)
|
||||
).run()
|
||||
|
||||
return flt(balance[0][0]) if balance else 0.0
|
||||
|
||||
def validate_stock_closing_entry(self):
|
||||
closing_entry = frappe.db.get_value(
|
||||
"Stock Closing Entry",
|
||||
apply_unscoped_filters(
|
||||
{"company": self.company, "to_date": self.period_end_date, "docstatus": 1}
|
||||
),
|
||||
["name", "status", "modified"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not closing_entry:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Create a Stock Closing Entry for the entire company with To Date as {0} before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry Required"),
|
||||
)
|
||||
|
||||
if closing_entry.status != "Completed":
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Stock Closing Entry for {0} is not completed yet. Wait for it to complete before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry In Progress"),
|
||||
)
|
||||
|
||||
self.validate_stock_closing_entry_is_fresh(closing_entry)
|
||||
|
||||
def validate_stock_closing_entry_is_fresh(self, closing_entry):
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
last_change = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Max(sle.modified))
|
||||
.where((sle.company == self.company) & (sle.posting_date <= self.period_end_date))
|
||||
).run()
|
||||
|
||||
if last_change and last_change[0][0] and last_change[0][0] > closing_entry.modified:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Stock transactions were created or modified after the Stock Closing Entry {0} was generated. Regenerate it before submitting the Period Closing Voucher."
|
||||
).format(get_link_to_form("Stock Closing Entry", closing_entry.name)),
|
||||
title=_("Stock Closing Entry Outdated"),
|
||||
)
|
||||
|
||||
def on_submit(self):
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
@@ -307,6 +307,218 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
repost_doc.posting_date = today()
|
||||
repost_doc.save()
|
||||
|
||||
def test_stock_validations_before_period_closing(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
create_custom_fields(
|
||||
{
|
||||
"Stock Closing Entry": [
|
||||
{
|
||||
"fieldname": "warehouse",
|
||||
"label": "Warehouse",
|
||||
"fieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
se = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": pcv.period_start_date,
|
||||
"to_date": pcv.period_end_date,
|
||||
"warehouse": "Stores - TPC",
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
frappe.db.set_value("Stock Closing Entry", sce.name, {"warehouse": None, "status": "In Progress"})
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is not completed yet", pcv.submit)
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": se.name},
|
||||
["name", "stock_value_difference"],
|
||||
as_dict=1,
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + 100
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not match", pcv.submit)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def test_batch_valuation_seeded_from_stock_closing_after_period_closing(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item(
|
||||
"Test PCV Batch Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TPCVB.####",
|
||||
},
|
||||
)
|
||||
se1 = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=200,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-06-15",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
|
||||
outward = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
from_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2022-04-01",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
stock_value_difference = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": outward.name, "is_cancelled": 0},
|
||||
"stock_value_difference",
|
||||
)
|
||||
self.assertEqual(flt(stock_value_difference, 2), -750.0)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"frozen",
|
||||
make_stock_entry,
|
||||
item_code=item.name,
|
||||
qty=1,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "frozen", se1.cancel)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "closed accounting period", sce.cancel)
|
||||
|
||||
def test_period_closing_blocks_stale_stock_closing_entry(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def make_completed_stock_closing_entry(self, from_date, to_date):
|
||||
from unittest.mock import patch
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
return sce
|
||||
|
||||
def rebuild_stock_closing_balance(self, sce):
|
||||
sce.remove_stock_closing()
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
def make_period_closing_voucher(self, posting_date, submit=True):
|
||||
surplus_account = create_account()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
@@ -234,15 +234,18 @@ def get_item_groups(pos_profile):
|
||||
for data in pos_profile.get("item_groups"):
|
||||
item_groups.extend(
|
||||
[
|
||||
"%s" % frappe.db.escape(d.name)
|
||||
d.name
|
||||
for d in get_child_nodes("Item Group", data.item_group)
|
||||
if not permitted_item_groups or d.name in permitted_item_groups
|
||||
]
|
||||
)
|
||||
|
||||
if not item_groups and permitted_item_groups:
|
||||
item_groups = ["%s" % frappe.db.escape(d) for d in permitted_item_groups]
|
||||
item_groups = list(permitted_item_groups)
|
||||
|
||||
# Return raw Item Group names; the callers parameterize them via the query builder
|
||||
# (item_group.isin(...)) / frappe.get_all, which escapes them once. Pre-escaping here would
|
||||
# double-escape (item_group IN ('''X''')) and match nothing.
|
||||
return list(set(item_groups))
|
||||
|
||||
|
||||
|
||||
@@ -467,19 +467,24 @@ def get_child_docs(doc: list) -> list:
|
||||
|
||||
|
||||
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
|
||||
docs_with_deferred_revenue = frappe.db.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
docs_with_deferred_revenue = ()
|
||||
docs_with_deferred_expense = ()
|
||||
|
||||
docs_with_deferred_expense = frappe.db.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
if sales_docs:
|
||||
docs_with_deferred_revenue = frappe.db.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
if purchase_docs:
|
||||
docs_with_deferred_expense = frappe.db.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
if docs_with_deferred_revenue or docs_with_deferred_expense:
|
||||
frappe.throw(
|
||||
|
||||
@@ -1180,7 +1180,16 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
}
|
||||
|
||||
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
|
||||
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
|
||||
// frm.set_df_property mutates a per-document copy, not the doctype's shared field
|
||||
// metadata, so this always reflects the original (Customize Form) hidden value.
|
||||
const hidden_by_customization = cint(
|
||||
frappe.meta.get_docfield("Sales Invoice", "update_stock")?.hidden
|
||||
);
|
||||
frm.set_df_property(
|
||||
"update_stock",
|
||||
"hidden",
|
||||
cint(frm.doc.has_subcontracted) || hidden_by_customization
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -254,6 +254,9 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
if self.status == "Cancelled":
|
||||
return
|
||||
|
||||
if self.is_trialling():
|
||||
self.status = "Trialing"
|
||||
elif (
|
||||
@@ -605,6 +608,11 @@ class Subscription(Document):
|
||||
1. `process_for_active`
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
# Snapshot before update_subscription_period() below can roll this forward,
|
||||
# so the cancel_at_period_end check further down still targets the period
|
||||
# that just ended, not the next one.
|
||||
current_period_end = self.current_invoice_end
|
||||
|
||||
if not self.is_current_invoice_generated(
|
||||
self.current_invoice_start, self.current_invoice_end
|
||||
) and self.can_generate_new_invoice(posting_date):
|
||||
@@ -625,8 +633,8 @@ class Subscription(Document):
|
||||
self.update_subscription_period()
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
getdate(posting_date) >= getdate(current_period_end)
|
||||
or (self.end_date and getdate(posting_date) >= getdate(self.end_date))
|
||||
):
|
||||
self.cancel_subscription()
|
||||
|
||||
|
||||
@@ -614,6 +614,32 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
|
||||
|
||||
def test_subscription_cancels_at_period_end_without_end_date(self):
|
||||
# https://github.com/frappe/erpnext/issues/57761 -- generate_invoice() rolls
|
||||
# current_invoice_end forward to the next period before this check runs, so
|
||||
# with no end_date to fall back on, cancel_at_period_end must compare
|
||||
# against the period that just ended, not the (already advanced) next one.
|
||||
create_plan(
|
||||
plan_name="_Test plan name 11",
|
||||
cost=80,
|
||||
currency="INR",
|
||||
billing_interval="Day",
|
||||
billing_interval_count=3,
|
||||
)
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
cancel_at_period_end=1,
|
||||
generate_invoice_at="End of the current subscription period",
|
||||
plans=[{"plan": "_Test plan name 11", "qty": 1}],
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
period_end = subscription.current_invoice_end
|
||||
|
||||
subscription.process(posting_date=period_end)
|
||||
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
|
||||
# The trigger date (period end) is long past, yet catch-up still bills the period
|
||||
# on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
|
||||
@@ -774,6 +800,38 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self):
|
||||
# https://github.com/frappe/erpnext/issues/57761
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
cancel_at_period_end=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
self.assertGreater(invoice.outstanding_amount, 0)
|
||||
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
cancelation_date = getdate(subscription.cancelation_date)
|
||||
self.assertIsNotNone(cancelation_date)
|
||||
|
||||
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
|
||||
payment_entry.reference_no = "12345"
|
||||
payment_entry.reference_date = nowdate()
|
||||
payment_entry.submit()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
|
||||
|
||||
invoice_count = len(subscription.invoices)
|
||||
subscription.process()
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), invoice_count)
|
||||
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
|
||||
@@ -854,9 +854,11 @@ def validate_account_party_type(self):
|
||||
|
||||
|
||||
def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
|
||||
|
||||
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
|
||||
if not frappe.has_permission(doctype, "read"):
|
||||
return None
|
||||
|
||||
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
|
||||
|
||||
companies = frappe.get_list(
|
||||
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
|
||||
|
||||
@@ -1893,7 +1893,7 @@ class AccountsController(TransactionBase):
|
||||
|
||||
def is_payable_account(self, reference_doctype, account):
|
||||
if reference_doctype == "Purchase Invoice" or (
|
||||
reference_doctype == "Journal Entry"
|
||||
reference_doctype in ("Journal Entry", "Payment Entry")
|
||||
and frappe.get_cached_value("Account", account, "account_type") == "Payable"
|
||||
):
|
||||
return True
|
||||
|
||||
@@ -70,9 +70,23 @@ QI_OUTGOING_PURPOSES = (
|
||||
)
|
||||
|
||||
|
||||
SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble")
|
||||
|
||||
|
||||
def is_inspection_exempt_secondary_row(doc, row) -> bool:
|
||||
"""Whether the row is a secondary item on a document that produces secondary items."""
|
||||
if not (row.get("type") or row.get("is_legacy_scrap_item")):
|
||||
return False
|
||||
|
||||
if doc.doctype == "Stock Entry":
|
||||
return doc.purpose in SECONDARY_ITEM_PURPOSES
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def stock_entry_row_requires_inspection(purpose, row):
|
||||
"""Check if this Stock Entry row need a Quality Inspection."""
|
||||
if row.get("type") or row.get("is_legacy_scrap_item"):
|
||||
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("type") or row.get("is_legacy_scrap_item")):
|
||||
return False
|
||||
if purpose == "Manufacture":
|
||||
return bool(row.is_finished_item)
|
||||
@@ -1604,7 +1618,7 @@ class StockController(AccountsController):
|
||||
elif self.doctype == "Stock Entry":
|
||||
qi_required = stock_entry_row_requires_inspection(self.purpose, row)
|
||||
|
||||
if row.get("type") or row.get("is_legacy_scrap_item"):
|
||||
if is_inspection_exempt_secondary_row(self, row):
|
||||
continue
|
||||
|
||||
if qi_required: # validate row only if inspection is required on item level
|
||||
|
||||
@@ -134,6 +134,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_cust_name()
|
||||
self.map_fields()
|
||||
self.validate_qty()
|
||||
self.set_exchange_rate()
|
||||
|
||||
if not self.title:
|
||||
@@ -144,6 +145,15 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
def on_update(self):
|
||||
self.update_prospect()
|
||||
|
||||
def validate_qty(self):
|
||||
for item in self.items:
|
||||
if flt(item.qty) <= 0:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
|
||||
item.idx, item.item_code
|
||||
)
|
||||
)
|
||||
|
||||
def map_fields(self):
|
||||
for field in self.meta.get_valid_columns():
|
||||
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):
|
||||
|
||||
3660
erpnext/locale/ar.po
3660
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/bg.po
3654
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
3674
erpnext/locale/bs.po
3674
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/cs.po
3654
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
22193
erpnext/locale/da.po
22193
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3662
erpnext/locale/de.po
3662
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/eo.po
3664
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3660
erpnext/locale/es.po
3660
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
3708
erpnext/locale/fa.po
3708
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
3658
erpnext/locale/fr.po
3658
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/hi.po
3656
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/hr.po
3664
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/hu.po
3656
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/id.po
3656
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/it.po
3654
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/ko.po
3656
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/my.po
3654
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/nb.po
3656
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/nl.po
3664
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/pl.po
3656
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/pt.po
3654
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
63124
erpnext/locale/ro.po
Normal file
63124
erpnext/locale/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
3666
erpnext/locale/ru.po
3666
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
3912
erpnext/locale/sl.po
3912
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
3662
erpnext/locale/sr.po
3662
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3668
erpnext/locale/sv.po
3668
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
3662
erpnext/locale/th.po
3662
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
3660
erpnext/locale/tr.po
3660
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/uz.po
3664
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/vi.po
3664
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
20554
erpnext/locale/zh.po
20554
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -881,10 +881,15 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
|
||||
warehouse_list = [warehouse_list]
|
||||
|
||||
if not warehouse_list:
|
||||
# Reconcile every warehouse the item has a non-zero balance in -- including
|
||||
# negative balances left by other tests. get_valuation_rate averages
|
||||
# Sum(stock_value)/Sum(actual_qty) across all bins, so a leftover negative
|
||||
# balance in one warehouse can cancel the reset qty elsewhere and make the
|
||||
# average collapse to 0, which is a source of flaky BOM-cost failures.
|
||||
warehouse_list = frappe.db.sql_list(
|
||||
"""
|
||||
select warehouse from `tabBin`
|
||||
where item_code=%s and actual_qty > 0
|
||||
where item_code=%s and actual_qty != 0
|
||||
""",
|
||||
item_code,
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<div class="row" style="border-bottom:1px solid var(--border-color); padding:4px 5px; margin-top: 3px;margin-bottom: 3px;">
|
||||
<div class="col-sm-1">
|
||||
{% if(row.image) { %}
|
||||
<img style="width:50px;height:50px;" src="{{row.image}}">
|
||||
<img style="width:50px;height:50px;" src="{{frappe.utils.escape_html(row.image)}}">
|
||||
{% } else { %}
|
||||
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(row.item_code, 2)}}</div>
|
||||
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(frappe.utils.escape_html(row.item_code), 2)}}</div>
|
||||
{% } %}
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
@@ -13,7 +13,7 @@
|
||||
{% } else { %}
|
||||
{{row.item_link}}
|
||||
<p>
|
||||
{{row.item_name}}
|
||||
{{frappe.utils.escape_html(row.item_name)}}
|
||||
</p>
|
||||
{% } %}
|
||||
|
||||
@@ -52,10 +52,10 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ escape(row.item_code) }}">{{ __("Add") }}</button>
|
||||
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Add") }}</button>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ escape(row.item_code) }}">{{ __("Move") }}</button>
|
||||
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Move") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% }); %}
|
||||
|
||||
@@ -1478,8 +1478,6 @@ def get_material_request_items(
|
||||
)
|
||||
)
|
||||
|
||||
required_qty = required_qty / row["conversion_factor"]
|
||||
|
||||
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
@@ -1498,10 +1496,11 @@ def get_material_request_items(
|
||||
get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
return {
|
||||
"item_code": row.item_code,
|
||||
"item_name": row.item_name,
|
||||
"quantity": required_qty / conversion_factor,
|
||||
"quantity": flt(required_qty / conversion_factor, precision),
|
||||
"conversion_factor": conversion_factor,
|
||||
"required_bom_qty": row.get("qty"),
|
||||
"stock_uom": row.get("stock_uom"),
|
||||
@@ -1910,7 +1909,7 @@ def get_materials_from_other_locations(
|
||||
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
item["quantity"] = required_qty / item.get("conversion_factor")
|
||||
item["quantity"] = flt(required_qty / item.get("conversion_factor"), precision)
|
||||
|
||||
new_mr_items.append(item)
|
||||
|
||||
|
||||
@@ -1366,6 +1366,29 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
self.assertEqual(row.uom, "Nos")
|
||||
self.assertEqual(row.qty, 1)
|
||||
|
||||
def test_material_request_item_quantity_rounded_to_precision(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
|
||||
bom_item = make_item(
|
||||
properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"}
|
||||
).name
|
||||
|
||||
if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}):
|
||||
doc = frappe.get_doc("Item", bom_item)
|
||||
doc.append("uoms", {"uom": "Nos", "conversion_factor": 3})
|
||||
doc.save()
|
||||
|
||||
make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
pln = create_production_plan(
|
||||
item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1, stock_uom="_Test UOM 1"
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
self.assertEqual(len(pln.mr_items), 1)
|
||||
self.assertEqual(pln.mr_items[0].quantity, flt(10 / 3, precision))
|
||||
|
||||
def test_material_request_for_sub_assembly_items(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
@@ -2079,6 +2102,40 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
self.assertEqual(row.get("uom"), "Nos")
|
||||
self.assertEqual(row.get("conversion_factor"), 10.0)
|
||||
|
||||
def test_remaining_purchase_qty_rounded_to_precision(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
|
||||
bom_item = make_item(
|
||||
properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"}
|
||||
).name
|
||||
|
||||
store_warehouse = create_warehouse("Store Warehouse", company="_Test Company")
|
||||
rm_warehouse = create_warehouse("RM Warehouse", company="_Test Company")
|
||||
|
||||
make_stock_entry(item_code=bom_item, qty=4, target=store_warehouse, rate=100)
|
||||
|
||||
if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}):
|
||||
doc = frappe.get_doc("Item", bom_item)
|
||||
doc.append("uoms", {"uom": "Nos", "conversion_factor": 3})
|
||||
doc.save()
|
||||
|
||||
make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
pln = create_production_plan(
|
||||
item_code=fg_item, planned_qty=30, stock_uom="_Test UOM 1", do_not_submit=1
|
||||
)
|
||||
pln.for_warehouse = rm_warehouse
|
||||
pln.ignore_existing_ordered_qty = 1
|
||||
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": store_warehouse}])
|
||||
|
||||
rows_by_type = {row.get("material_request_type"): row for row in items}
|
||||
self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4)
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
self.assertEqual(rows_by_type["Purchase"].get("quantity"), flt(26 / 3, precision))
|
||||
|
||||
def test_unreserve_qty_on_closing_of_pp(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
@@ -513,7 +513,7 @@ def get_workstations(**kwargs):
|
||||
d.color = color_map.get(d.status, "red")
|
||||
d.workstation_link = get_url_to_form("Workstation", d.name)
|
||||
if d.status != "Production":
|
||||
d.status_image = d.off_status_image
|
||||
d.status_image = frappe.utils.escape_html(d.off_status_image)
|
||||
d.workstation_off = "workstation-off"
|
||||
|
||||
return data
|
||||
|
||||
@@ -32,18 +32,7 @@ class BOMConfigurator {
|
||||
}
|
||||
|
||||
bind_events() {
|
||||
frappe.views.trees["BOM Configurator"].events = {
|
||||
frm: this.frm,
|
||||
add_item: this.add_item,
|
||||
add_sub_assembly: this.add_sub_assembly,
|
||||
set_query_for_workstation: this.set_query_for_workstation,
|
||||
get_sub_assembly_modal_fields: this.get_sub_assembly_modal_fields,
|
||||
convert_to_sub_assembly: this.convert_to_sub_assembly,
|
||||
delete_node: this.delete_node,
|
||||
edit_bom: this.edit_bom,
|
||||
load_tree: this.load_tree,
|
||||
set_default_qty: this.set_default_qty,
|
||||
};
|
||||
frappe.views.trees["BOM Configurator"].events = this;
|
||||
}
|
||||
|
||||
tree_options() {
|
||||
|
||||
@@ -20,8 +20,10 @@ erpnext.stock.qi_outgoing_purposes = [
|
||||
];
|
||||
erpnext.stock.is_incoming_qi_purpose = (purpose) =>
|
||||
purpose === "Manufacture" || erpnext.stock.qi_incoming_purposes.includes(purpose);
|
||||
erpnext.stock.secondary_item_purposes = ["Manufacture", "Repack", "Disassemble"];
|
||||
erpnext.stock.row_requires_quality_inspection = (purpose, row) => {
|
||||
if (row.type || row.is_legacy_scrap_item) return false;
|
||||
if (erpnext.stock.secondary_item_purposes.includes(purpose) && (row.type || row.is_legacy_scrap_item))
|
||||
return false;
|
||||
if (purpose === "Manufacture") return !!row.is_finished_item;
|
||||
if (erpnext.stock.qi_incoming_purposes.includes(purpose)) return !!row.t_warehouse;
|
||||
if (erpnext.stock.qi_outgoing_purposes.includes(purpose))
|
||||
|
||||
@@ -168,7 +168,7 @@ class VisualPlantFloor {
|
||||
.find(".workstation-image-container")
|
||||
.append(
|
||||
`<div class="workstation-image-cls workstation-abbr" style="margin:6px; height:82px">${frappe.get_abbr(
|
||||
data.name,
|
||||
frappe.utils.escape_html(data.name),
|
||||
2
|
||||
)}</div>`
|
||||
);
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<div class="app-listing item-list image-view-container item-selector">
|
||||
{% for (var i=0; i < data.length; i++) { var item = data[i]; %}
|
||||
{% const item_name = frappe.utils.escape_html(item.name); %}
|
||||
{% const item_title = frappe.utils.escape_html(item.item_name || item.name); %}
|
||||
{% if (i % 4 === 0) { %}<div class="image-view-row">{% } %}
|
||||
<div class="image-view-item" data-name="{{ item.name }}">
|
||||
<div class="image-view-item" data-name="{{ item_name }}">
|
||||
<div class="image-view-header doclist-row">
|
||||
<div class="list-value">
|
||||
<a class="grey list-id" data-name="{{item.name}}"
|
||||
title="{{ item.item_name || item.name}}">
|
||||
{{item.item_name || item.name}}</a>
|
||||
<a class="grey list-id" data-name="{{ item_name }}"
|
||||
title="{{ item_title }}">
|
||||
{{ item_title }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-view-body">
|
||||
<a data-item-code="{{ item.name }}"
|
||||
title="{{ item.item_name || item.name }}"
|
||||
<a data-item-code="{{ item_name }}"
|
||||
title="{{ item_title }}"
|
||||
>
|
||||
<div class="image-field"
|
||||
style="
|
||||
@@ -22,11 +24,11 @@
|
||||
>
|
||||
{% if (!item.image) { %}
|
||||
<span class="placeholder-text">
|
||||
{%= frappe.get_abbr(item.item_name || item.name) %}
|
||||
{%= frappe.get_abbr(item_title) %}
|
||||
</span>
|
||||
{% } %}
|
||||
{% if (item.image) { %}
|
||||
<img src="{{ item.image }}" alt="{{item.item_name || item.name}}">
|
||||
<img src="{{ frappe.utils.escape_html(item.image) }}" alt="{{ item_title }}">
|
||||
{% } %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% $.each(workstations, (idx, row) => { %}
|
||||
<div class="workstation-wrapper" data-workstation="{{row.name}}">
|
||||
{% const row_workstation_name = frappe.utils.escape_html(row.name); %}
|
||||
<div class="workstation-wrapper" data-workstation="{{row_workstation_name}}">
|
||||
<div class="workstation-status text-left" style="">
|
||||
<span class="indicator-pill no-indicator-dot whitespace-nowrap {{row.color}}" style="margin: 8px 0px 0px 8px;">
|
||||
<span class="workstation-status-title" style="font-size:10px">{{row.status}}</span>
|
||||
@@ -10,12 +11,12 @@
|
||||
{% if(row.status_image) { %}
|
||||
<img class="workstation-image-cls" src="{{row.status_image}}">
|
||||
{% } else { %}
|
||||
<div class="workstation-image-cls workstation-abbr" style="margin:6px; height:82px">{{frappe.get_abbr(row.name, 2)}}</div>
|
||||
<div class="workstation-image-cls workstation-abbr" style="margin:6px; height:82px">{{frappe.get_abbr(row_workstation_name, 2)}}</div>
|
||||
{% } %}
|
||||
</div>
|
||||
<span class="ellipsis" title="{{row.name}}">
|
||||
<span class="ellipsis" title="{{row_workstation_name}}">
|
||||
<div style="font-size:11px; text-align:center;padding-bottom:8px">{{row.workstation_name}}</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% }); %}
|
||||
{% }); %}
|
||||
|
||||
@@ -159,6 +159,9 @@ class DeprecatedBatchNoValuation:
|
||||
if self.sle.name:
|
||||
query = query.where(sle.name != self.sle.name)
|
||||
|
||||
if getattr(self, "stock_closing_from_datetime", None):
|
||||
query = query.where(sle.posting_datetime >= self.stock_closing_from_datetime)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
@deprecated(
|
||||
|
||||
@@ -853,7 +853,17 @@ class Item(Document):
|
||||
frappe.throw(_("Item {0} is not a template item.").format(frappe.bold(self.variant_of)))
|
||||
|
||||
if based_on == "Item Attribute":
|
||||
previous_doc = self.get_doc_before_save()
|
||||
saved_attributes = (
|
||||
{(row.attribute, row.attribute_value) for row in previous_doc.attributes}
|
||||
if previous_doc
|
||||
else set()
|
||||
)
|
||||
|
||||
for d in self.attributes:
|
||||
if (d.attribute, d.attribute_value) in saved_attributes:
|
||||
continue
|
||||
|
||||
if not frappe.db.exists(
|
||||
"Item Variant Attribute", {"attribute": d.attribute, "parent": self.variant_of}
|
||||
):
|
||||
|
||||
@@ -410,6 +410,24 @@ class TestItem(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(InvalidItemAttributeValueError, attribute.save)
|
||||
|
||||
def test_disabled_attribute_blocks_only_attribute_changes(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
|
||||
variant.save()
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
attribute.disabled = 1
|
||||
attribute.save()
|
||||
|
||||
variant.reload()
|
||||
variant.description = "Edited after the attribute was disabled"
|
||||
variant.save()
|
||||
|
||||
variant.reload()
|
||||
variant.attributes[0].attribute_value = "Small"
|
||||
self.assertRaises(frappe.ValidationError, variant.save)
|
||||
|
||||
def test_rename_attribute_value_updates_variants(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
|
||||
@@ -533,7 +533,7 @@ frappe.ui.form.on("Material Request", {
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Create"),
|
||||
primary_action: async function (values) {
|
||||
primary_action: function (values) {
|
||||
const item_suppliers = (values.items || []).filter((row) => row.__checked);
|
||||
if (!item_suppliers.length) {
|
||||
frappe.throw(__("Select at least one Item"));
|
||||
@@ -567,10 +567,6 @@ frappe.ui.form.on("Material Request", {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await erpnext.utils.confirm_if_drafts_exist(frm.doc, "Purchase Order"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.material_request.material_request.make_purchase_orders_by_supplier",
|
||||
args: { source_name: frm.doc.name, item_suppliers: item_suppliers },
|
||||
|
||||
@@ -5611,6 +5611,66 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]])
|
||||
|
||||
def test_purchase_return_valuation_for_batchwise_valuation_batch(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
item_code = make_item(
|
||||
"Test Purchase Return Batchwise Valn Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"batch_number_series": "BN-TPRBWV-.#####",
|
||||
},
|
||||
).name
|
||||
|
||||
batch_no = "BN-TPRBWV-00001"
|
||||
batch = frappe.new_doc("Batch").update({"batch_id": batch_no, "item": item_code}).insert()
|
||||
self.assertEqual(batch.use_batchwise_valuation, 1)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
qty=100,
|
||||
rate=1000,
|
||||
warehouse=warehouse,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
qty=100,
|
||||
rate=400,
|
||||
warehouse=warehouse,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
create_delivery_note(
|
||||
item_code=item_code,
|
||||
qty=100,
|
||||
warehouse=warehouse,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
|
||||
return_pr = make_return_doc("Purchase Receipt", pr.name)
|
||||
return_pr.submit()
|
||||
|
||||
sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": return_pr.name, "is_cancelled": 0},
|
||||
["stock_value_difference", "qty_after_transaction", "stock_value", "serial_and_batch_bundle"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(flt(sle.qty_after_transaction), 0.0)
|
||||
self.assertEqual(flt(sle.stock_value_difference, 2), -70000.0)
|
||||
self.assertEqual(flt(sle.stock_value, 2), 0.0)
|
||||
|
||||
rate = frappe.db.get_value(
|
||||
"Serial and Batch Entry", {"parent": sle.serial_and_batch_bundle}, "incoming_rate"
|
||||
)
|
||||
self.assertEqual(flt(rate, 2), 700.0)
|
||||
|
||||
def test_negative_stock_error_for_purchase_return(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
@@ -414,6 +414,13 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
valuation_method = get_valuation_method(self.item_code, self.company)
|
||||
|
||||
# An outward return must go out at the batch's current average rate for a
|
||||
# batchwise valuation batch. The original receipt rate is only correct while
|
||||
# the batch still holds stock at that rate; once other receipts have changed
|
||||
# the average, removing at the original rate strands a residue in the batch
|
||||
# value (negative when returning the costlier receipt).
|
||||
batchwise_avg_rates = self.get_batchwise_return_avg_rates()
|
||||
|
||||
stock_queue = []
|
||||
non_batchwise_batches = []
|
||||
if not self.has_serial_no and valuation_method == "FIFO":
|
||||
@@ -447,6 +454,12 @@ class SerialandBatchBundle(Document):
|
||||
batches = sorted(list(valuation_details["batches"].keys()))
|
||||
valuation_rate = valuation_details["batches"].get(batches[cint(row.idx) - 1])
|
||||
|
||||
# a batch with an available balance goes out at its current average rate (a
|
||||
# valid 0.0 included); the original receipt rate applies only when there is
|
||||
# no balance to average
|
||||
if not row.serial_no and row.batch_no in batchwise_avg_rates:
|
||||
valuation_rate = batchwise_avg_rates[row.batch_no]
|
||||
|
||||
row.incoming_rate = flt(valuation_rate)
|
||||
row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate)
|
||||
|
||||
@@ -475,6 +488,43 @@ class SerialandBatchBundle(Document):
|
||||
elif self.type_of_transaction == "Inward":
|
||||
self.set_incoming_rate_for_inward_transaction(row, save, prev_sle=prev_sle)
|
||||
|
||||
def get_batchwise_return_avg_rates(self):
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
if self.type_of_transaction != "Outward" or self.has_serial_no:
|
||||
return {}
|
||||
|
||||
batch_nos = [d.batch_no for d in self.entries if d.batch_no]
|
||||
if not batch_nos:
|
||||
return {}
|
||||
|
||||
if get_valuation_method(
|
||||
self.item_code, self.company
|
||||
) == "Moving Average" and frappe.db.get_single_value(
|
||||
"Stock Settings", "do_not_use_batchwise_valuation"
|
||||
):
|
||||
return {}
|
||||
|
||||
batchwise_batches = frappe.get_all(
|
||||
"Batch",
|
||||
filters={"name": ("in", batch_nos), "use_batchwise_valuation": 1},
|
||||
pluck="name",
|
||||
)
|
||||
if not batchwise_batches:
|
||||
return {}
|
||||
|
||||
# scoped to batchwise batches only, so BatchNoValuation's non-batchwise
|
||||
# machinery never runs for them
|
||||
sle = self.get_sle_for_outward_transaction()
|
||||
sle.batch_nos = {batch_no: sle.batch_nos[batch_no] for batch_no in batchwise_batches}
|
||||
sle.batchwise_valuation_batches = batchwise_batches
|
||||
sn_obj = BatchNoValuation(sle=sle, item_code=self.item_code, warehouse=self.warehouse)
|
||||
return {
|
||||
batch_no: abs(flt(sn_obj.batch_avg_rate.get(batch_no)))
|
||||
for batch_no in batchwise_batches
|
||||
if flt(sn_obj.available_qty.get(batch_no))
|
||||
}
|
||||
|
||||
def validate_returned_serial_batch_no(self, return_against, row, original_inv_details):
|
||||
if frappe.flags.through_repost_item_valuation and not frappe.in_test:
|
||||
return
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, nowtime, today
|
||||
from frappe.utils import add_days, add_to_date, flt, nowtime, today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
@@ -1601,3 +1601,190 @@ class TestSerialandBatchBundleLogic(ERPNextTestSuite):
|
||||
|
||||
self.assertNotIn(bundles[1], bundle_wise_serial_nos)
|
||||
self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no])
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1}
|
||||
)
|
||||
def test_batchwise_valuation_for_same_posting_datetime_entries(self):
|
||||
# an inward at a different rate and multiple outward rows with the same
|
||||
# item and warehouse share the same posting datetime, the tie-breaking
|
||||
# must include the same-timestamp entries which are already part of the
|
||||
# ledger and must not let the outward rows count each other
|
||||
item_code = make_item(
|
||||
"Test Batchwise Same Posting Datetime Item 1",
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBSPD-ITEM1-.#####",
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rate=100,
|
||||
target=warehouse,
|
||||
posting_date=add_days(today(), -5),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
|
||||
self.assertTrue(frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"))
|
||||
|
||||
# same posting datetime as the outward rows below, at a different rate
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=20,
|
||||
rate=250,
|
||||
target=warehouse,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(today(), -3),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
issue = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=2,
|
||||
source=warehouse,
|
||||
posting_date=add_days(today(), -3),
|
||||
posting_time="12:00:00",
|
||||
do_not_save=True,
|
||||
)
|
||||
|
||||
for qty in [3, 4]:
|
||||
issue.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code,
|
||||
"s_warehouse": warehouse,
|
||||
"qty": qty,
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
|
||||
issue.save()
|
||||
issue.submit()
|
||||
|
||||
# (10 * 100 + 20 * 250) / 30 = 200
|
||||
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=200.0, balance_value=4200.0)
|
||||
|
||||
# backdated receipt reposts the same posting datetime cluster
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rate=100,
|
||||
target=warehouse,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(today(), -4),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
# (20 * 100 + 20 * 250) / 40 = 175
|
||||
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=175.0, balance_value=5425.0)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1}
|
||||
)
|
||||
def test_batchwise_valuation_when_bundle_created_before_the_sle(self):
|
||||
# a bundle can be created (drafted) much before / after its SLE, the
|
||||
# tie-breaking for the same posting datetime entries must follow the
|
||||
# SLE creation and not the bundle creation
|
||||
item_code = make_item(
|
||||
"Test Batchwise Same Posting Datetime Item 2",
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBSPD-ITEM2-.#####",
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rate=100,
|
||||
target=warehouse,
|
||||
posting_date=add_days(today(), -5),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
|
||||
|
||||
# inward at a different rate, same posting datetime as the outward below
|
||||
inward = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rate=200,
|
||||
target=warehouse,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(today(), -3),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
outward = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
source=warehouse,
|
||||
posting_date=add_days(today(), -3),
|
||||
posting_time="12:00:00",
|
||||
)
|
||||
|
||||
# simulate the inward's bundle drafted after the outward's SLE, the
|
||||
# bundle creation timeline no longer matches the SLE creation timeline
|
||||
outward_sle_creation = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": outward.name, "is_cancelled": 0},
|
||||
"creation",
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Serial and Batch Bundle",
|
||||
inward.items[0].serial_and_batch_bundle,
|
||||
"creation",
|
||||
add_to_date(outward_sle_creation, minutes=30),
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
repost = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Item and Warehouse",
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": add_days(today(), -6),
|
||||
"posting_time": "00:00:00",
|
||||
"allow_negative_stock": 1,
|
||||
}
|
||||
)
|
||||
|
||||
repost.submit()
|
||||
|
||||
# (10 * 100 + 10 * 200) / 20 = 150, the inward precedes the outward as
|
||||
# per the SLE creation even though its bundle was created afterwards
|
||||
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=150.0, balance_value=1500.0)
|
||||
|
||||
def assert_batchwise_outgoing_rate(self, item_code, outgoing_rate, balance_value):
|
||||
sl_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"item_code": item_code, "is_cancelled": 0},
|
||||
fields=["actual_qty", "stock_value_difference", "stock_value"],
|
||||
order_by="posting_datetime, creation",
|
||||
)
|
||||
|
||||
for sle in sl_entries:
|
||||
if sle.actual_qty > 0:
|
||||
continue
|
||||
|
||||
self.assertEqual(flt(sle.stock_value_difference, 2), flt(sle.actual_qty * outgoing_rate, 2))
|
||||
|
||||
self.assertEqual(flt(sl_entries[-1].stock_value, 2), flt(balance_value, 2))
|
||||
|
||||
@@ -10,9 +10,51 @@ from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_days, get_date_str, get_link_to_form, nowtime, parse_json
|
||||
from frappe.utils.background_jobs import enqueue
|
||||
from frappe.utils.caching import request_cache
|
||||
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
|
||||
SCOPE_FIELDS = ("warehouse", "item_code", "item_group", "warehouse_type")
|
||||
|
||||
|
||||
def apply_unscoped_filters(filters):
|
||||
meta = frappe.get_meta("Stock Closing Entry")
|
||||
for fieldname in SCOPE_FIELDS:
|
||||
if meta.has_field(fieldname):
|
||||
filters[fieldname] = ("is", "not set")
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def get_closing_entry_for_closed_period(company):
|
||||
closed_upto = frappe.db.get_value(
|
||||
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
|
||||
)
|
||||
if not closed_upto:
|
||||
return None
|
||||
|
||||
return _get_completed_closing_entry(company, str(closed_upto))
|
||||
|
||||
|
||||
@request_cache
|
||||
def _get_completed_closing_entry(company, closed_upto):
|
||||
filters = apply_unscoped_filters(
|
||||
{
|
||||
"company": company,
|
||||
"docstatus": 1,
|
||||
"status": "Completed",
|
||||
"to_date": ("<=", closed_upto),
|
||||
}
|
||||
)
|
||||
|
||||
return frappe.db.get_value(
|
||||
"Stock Closing Entry",
|
||||
filters,
|
||||
["name", "to_date"],
|
||||
order_by="to_date desc",
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
|
||||
class StockClosingEntry(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -68,7 +110,7 @@ class StockClosingEntry(Document):
|
||||
)
|
||||
)
|
||||
|
||||
for fieldname in ["warehouse", "item_code", "item_group", "warehouse_type"]:
|
||||
for fieldname in SCOPE_FIELDS:
|
||||
if self.get(fieldname):
|
||||
query = query.where(table[fieldname] == self.get(fieldname))
|
||||
|
||||
@@ -86,14 +128,30 @@ class StockClosingEntry(Document):
|
||||
self.enqueue_job()
|
||||
|
||||
def on_cancel(self):
|
||||
self.validate_closed_period_lock()
|
||||
self.set_status(save=True)
|
||||
self.remove_stock_closing()
|
||||
|
||||
def validate_closed_period_lock(self):
|
||||
pcv = frappe.db.get_value(
|
||||
"Period Closing Voucher",
|
||||
{"company": self.company, "docstatus": 1, "period_end_date": (">=", self.to_date)},
|
||||
"name",
|
||||
)
|
||||
|
||||
if pcv:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Stock Closing Entry {0} belongs to a closed accounting period. Cancel the Period Closing Voucher {1} first."
|
||||
).format(self.name, get_link_to_form("Period Closing Voucher", pcv)),
|
||||
title=_("Closed Period"),
|
||||
)
|
||||
|
||||
def remove_stock_closing(self):
|
||||
table = frappe.qb.DocType("Stock Closing Balance")
|
||||
frappe.qb.from_(table).delete().where(table.stock_closing_entry == self.name).run()
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def enqueue_job(self):
|
||||
self.db_set("status", "In Progress")
|
||||
enqueue(prepare_closing_stock_balance, name=self.name, queue="long", timeout=1500)
|
||||
@@ -103,8 +161,9 @@ class StockClosingEntry(Document):
|
||||
).format(self.name)
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def regenerate_closing_balance(self):
|
||||
self.validate_closed_period_lock()
|
||||
self.remove_stock_closing()
|
||||
self.enqueue_job()
|
||||
|
||||
|
||||
@@ -83,6 +83,15 @@ from erpnext.controllers.subcontracting_inward_controller import SubcontractingI
|
||||
form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"}
|
||||
|
||||
|
||||
def is_costed_out_of_finished_item(row) -> bool:
|
||||
"""Whether the row takes its value out of the finished good instead of adding to it.
|
||||
|
||||
A secondary item that is not linked to a BOM has no cost allocation of its own, so it is
|
||||
valued the way the legacy scrap item was: its cost is deducted from the finished good.
|
||||
"""
|
||||
return bool(row.is_legacy_scrap_item or (row.type and not row.bom_secondary_item))
|
||||
|
||||
|
||||
def _qty_tolerance(precision: int) -> float:
|
||||
"""One unit at the column's precision -- absorbs float rounding without letting a real
|
||||
(whole-unit) quantity divergence slip through."""
|
||||
@@ -1447,9 +1456,12 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
|
||||
has_consumption_basis = self.has_consumption_basis()
|
||||
|
||||
secondary_items_cost_basis = self.get_secondary_items_cost_basis(outgoing_items_cost)
|
||||
|
||||
items = []
|
||||
# Set basic rate for incoming items
|
||||
for d in self.get("items"):
|
||||
finished_items_last = sorted(self.get("items"), key=lambda row: cint(row.is_finished_item))
|
||||
for d in finished_items_last:
|
||||
if d.s_warehouse or d.set_basic_rate_manually:
|
||||
continue
|
||||
|
||||
@@ -1459,7 +1471,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
d.basic_amount = 0.0
|
||||
continue
|
||||
|
||||
rate_derived_from_consumption = False
|
||||
has_derived_rate = False
|
||||
|
||||
if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer":
|
||||
d.basic_rate = 0.0
|
||||
@@ -1469,26 +1481,25 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
d.basic_rate = self.get_basic_rate_for_manufactured_item(
|
||||
d.transfer_qty, outgoing_items_cost, has_consumption_basis
|
||||
)
|
||||
rate_derived_from_consumption = has_consumption_basis
|
||||
has_derived_rate = has_consumption_basis
|
||||
elif self.purpose == "Repack":
|
||||
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
|
||||
# Repack rate comes from consumed source-warehouse rows, not consumption entries
|
||||
rate_derived_from_consumption = any(item.s_warehouse for item in self.get("items"))
|
||||
has_derived_rate = any(item.s_warehouse for item in self.get("items"))
|
||||
|
||||
if self.bom_no:
|
||||
d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100
|
||||
elif d.type and d.bom_secondary_item:
|
||||
cost_allocation_per = frappe.get_value(
|
||||
"BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per"
|
||||
cost_allocation_per = flt(
|
||||
frappe.get_value("BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per")
|
||||
)
|
||||
# Only recalculate when cost is actually allocated; otherwise preserve the
|
||||
# user-entered rate (or fall through to get_valuation_rate below)
|
||||
if cost_allocation_per and flt(d.transfer_qty):
|
||||
d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty
|
||||
if flt(d.transfer_qty):
|
||||
d.basic_rate = (secondary_items_cost_basis * (cost_allocation_per / 100)) / d.transfer_qty
|
||||
has_derived_rate = True
|
||||
|
||||
# A rate of zero derived from the consumed items is their actual cost, not a missing
|
||||
# rate. Falling back to the item's valuation here would value free inputs as output.
|
||||
if not d.basic_rate and not d.allow_zero_valuation_rate and not rate_derived_from_consumption:
|
||||
# A rate of zero that was derived rather than left unset is a real cost. Falling back to
|
||||
# the item's valuation here would value free inputs, or an unallocated row, as output.
|
||||
if not d.basic_rate and not d.allow_zero_valuation_rate and not has_derived_rate:
|
||||
if self.is_new():
|
||||
raise_error_if_no_rate = False
|
||||
|
||||
@@ -1601,11 +1612,43 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
)
|
||||
return flt(outgoing_items_cost / total_fg_qty)
|
||||
|
||||
def get_secondary_items_cost_basis(self, outgoing_items_cost) -> float:
|
||||
"""The cost a BOM allocation splits: the consumed rows, or the entry that replaced them."""
|
||||
if outgoing_items_cost or self.purpose != "Manufacture" or not self.work_order:
|
||||
return outgoing_items_cost
|
||||
|
||||
settings = frappe.get_single("Manufacturing Settings")
|
||||
if not (settings.material_consumption and settings.get_rm_cost_from_consumption_entry):
|
||||
return outgoing_items_cost
|
||||
|
||||
if not self.get_consumption_entries():
|
||||
return outgoing_items_cost
|
||||
|
||||
return self._fetch_consumption_entry_cost()
|
||||
|
||||
def _fetch_consumption_entry_cost(self):
|
||||
SE = frappe.qb.DocType("Stock Entry")
|
||||
SE_ITEM = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
return (
|
||||
frappe.qb.from_(SE)
|
||||
.left_join(SE_ITEM)
|
||||
.on(SE.name == SE_ITEM.parent)
|
||||
.select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty))
|
||||
.where(
|
||||
(SE.docstatus == 1)
|
||||
& (SE.work_order == self.work_order)
|
||||
& (SE.purpose == "Material Consumption for Manufacture")
|
||||
)
|
||||
).run()[0][0] or 0
|
||||
|
||||
def get_basic_rate_for_manufactured_item(
|
||||
self, finished_item_qty, outgoing_items_cost=0, has_consumption_basis=False
|
||||
) -> float:
|
||||
settings = frappe.get_single("Manufacturing Settings")
|
||||
scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_legacy_scrap_item])
|
||||
scrap_items_cost = sum(
|
||||
[flt(d.basic_amount) for d in self.get("items") if is_costed_out_of_finished_item(d)]
|
||||
)
|
||||
|
||||
if settings.material_consumption:
|
||||
if settings.get_rm_cost_from_consumption_entry and self.work_order:
|
||||
@@ -1642,20 +1685,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
)
|
||||
)
|
||||
|
||||
SE = frappe.qb.DocType("Stock Entry")
|
||||
SE_ITEM = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
outgoing_items_cost = (
|
||||
frappe.qb.from_(SE)
|
||||
.left_join(SE_ITEM)
|
||||
.on(SE.name == SE_ITEM.parent)
|
||||
.select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty))
|
||||
.where(
|
||||
(SE.docstatus == 1)
|
||||
& (SE.work_order == self.work_order)
|
||||
& (SE.purpose == "Material Consumption for Manufacture")
|
||||
)
|
||||
).run()[0][0] or 0
|
||||
outgoing_items_cost = self._fetch_consumption_entry_cost()
|
||||
|
||||
# Estimate from the BOM only when nothing was consumed. A consumed cost of zero is a
|
||||
# real cost, so substituting BOM rates would value free inputs as output.
|
||||
@@ -2031,7 +2061,9 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
for d in self.items:
|
||||
if d.t_warehouse and not d.s_warehouse:
|
||||
if self.purpose == "Repack" or d.item_code == finished_item:
|
||||
if d.type or d.is_legacy_scrap_item:
|
||||
d.is_finished_item = 0
|
||||
elif self.purpose == "Repack" or d.item_code == finished_item:
|
||||
d.is_finished_item = 1
|
||||
else:
|
||||
d.is_finished_item = 0
|
||||
|
||||
@@ -7,6 +7,7 @@ from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import get_inventory_account
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.exceptions import QualityInspectionRequiredError
|
||||
from erpnext.stock.doctype.item.test_item import (
|
||||
create_item,
|
||||
make_item,
|
||||
@@ -2737,6 +2738,254 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
self.assertEqual(fg_sle.incoming_rate, 0)
|
||||
self.assertEqual(fg_sle.stock_value_difference, 0)
|
||||
|
||||
def test_manufacture_balances_secondary_item_added_without_a_bom(self):
|
||||
"""A secondary item with no BOM link is costed out of the finished good, as legacy scrap was."""
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
make_stock_entry(item_code=rm_item, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.purpose = se.stock_entry_type = "Manufacture"
|
||||
se.company = "_Test Company"
|
||||
se.append(
|
||||
"items", {"item_code": rm_item, "s_warehouse": warehouse, "qty": 10, "conversion_factor": 1}
|
||||
)
|
||||
se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": fg_item,
|
||||
"t_warehouse": warehouse,
|
||||
"qty": 10,
|
||||
"is_finished_item": 1,
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": scrap_item,
|
||||
"t_warehouse": warehouse,
|
||||
"qty": 5,
|
||||
"type": "Scrap",
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
se.save()
|
||||
|
||||
scrap_row = se.items[2]
|
||||
self.assertEqual(flt(scrap_row.basic_rate), 20.0)
|
||||
self.assertEqual(flt(scrap_row.basic_amount), 100.0)
|
||||
|
||||
fg_row = se.items[1]
|
||||
self.assertEqual(flt(fg_row.basic_rate), 90.0)
|
||||
self.assertEqual(flt(fg_row.basic_amount), 900.0)
|
||||
|
||||
self.assertEqual(flt(se.total_incoming_value), 1000.0)
|
||||
self.assertEqual(flt(se.total_outgoing_value), 1000.0)
|
||||
self.assertEqual(flt(se.value_difference), 0.0)
|
||||
|
||||
def test_repack_allocates_cost_to_secondary_item(self):
|
||||
"""A Repack secondary item takes its own BOM share, not the finished good's."""
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
bom = frappe.get_doc(
|
||||
{
|
||||
"doctype": "BOM",
|
||||
"item": fg_item,
|
||||
"currency": "INR",
|
||||
"quantity": 10,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 10})
|
||||
bom.append(
|
||||
"secondary_items",
|
||||
{
|
||||
"type": "Scrap",
|
||||
"item_code": scrap_item,
|
||||
"item_name": scrap_item,
|
||||
"qty": 5,
|
||||
"cost_allocation_per": 25,
|
||||
"process_loss_per": 0,
|
||||
},
|
||||
)
|
||||
bom.insert()
|
||||
bom.submit()
|
||||
self.assertEqual(flt(bom.cost_allocation_per), 75.0)
|
||||
|
||||
make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.purpose = se.stock_entry_type = "Repack"
|
||||
se.company = "_Test Company"
|
||||
se.from_bom = 1
|
||||
se.bom_no = bom.name
|
||||
se.fg_completed_qty = 10
|
||||
se.from_warehouse = warehouse
|
||||
se.to_warehouse = warehouse
|
||||
se.get_items()
|
||||
se.save()
|
||||
|
||||
fg_row = next(d for d in se.items if d.is_finished_item)
|
||||
scrap_row = next(d for d in se.items if d.type)
|
||||
|
||||
self.assertFalse(scrap_row.is_finished_item)
|
||||
self.assertEqual(flt(scrap_row.basic_amount), 250.0)
|
||||
self.assertEqual(flt(fg_row.basic_amount), 750.0)
|
||||
|
||||
self.assertEqual(flt(se.total_incoming_value), 1000.0)
|
||||
self.assertEqual(flt(se.total_outgoing_value), 1000.0)
|
||||
self.assertEqual(flt(se.value_difference), 0.0)
|
||||
|
||||
def test_secondary_item_with_zero_cost_allocation_carries_no_value(self):
|
||||
"""A BOM that allocates 0% to a secondary item gives the finished good everything."""
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_from_wo,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
bom = frappe.get_doc(
|
||||
{
|
||||
"doctype": "BOM",
|
||||
"item": fg_item,
|
||||
"currency": "INR",
|
||||
"quantity": 10,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 10})
|
||||
bom.append(
|
||||
"secondary_items",
|
||||
{
|
||||
"type": "Scrap",
|
||||
"item_code": scrap_item,
|
||||
"item_name": scrap_item,
|
||||
"qty": 5,
|
||||
"cost_allocation_per": 0,
|
||||
"process_loss_per": 0,
|
||||
},
|
||||
)
|
||||
bom.insert()
|
||||
bom.submit()
|
||||
self.assertEqual(flt(bom.cost_allocation_per), 100.0)
|
||||
|
||||
make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100)
|
||||
wo = make_wo_order_test_record(
|
||||
production_item=fg_item, bom_no=bom.name, qty=10, skip_transfer=1, source_warehouse=warehouse
|
||||
)
|
||||
|
||||
se = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
|
||||
se.save()
|
||||
|
||||
scrap_row = next(d for d in se.items if d.type)
|
||||
fg_row = next(d for d in se.items if d.is_finished_item)
|
||||
|
||||
self.assertEqual(flt(scrap_row.basic_rate), 0.0)
|
||||
self.assertEqual(flt(scrap_row.basic_amount), 0.0)
|
||||
self.assertEqual(flt(fg_row.basic_amount), 1000.0)
|
||||
self.assertEqual(flt(se.value_difference), 0.0)
|
||||
|
||||
def test_secondary_item_type_does_not_waive_inspection_outside_manufacturing(self):
|
||||
"""A stray secondary item type must not let a QI-required item through a receipt."""
|
||||
item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"valuation_rate": 50,
|
||||
"inspection_required_before_purchase": 1,
|
||||
}
|
||||
).name
|
||||
|
||||
def receipt(secondary_item_type):
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.purpose = se.stock_entry_type = "Material Receipt"
|
||||
se.company = "_Test Company"
|
||||
se.inspection_required = 1
|
||||
se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item,
|
||||
"t_warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 10,
|
||||
"conversion_factor": 1,
|
||||
"type": secondary_item_type,
|
||||
},
|
||||
)
|
||||
return se
|
||||
|
||||
self.assertRaises(QualityInspectionRequiredError, receipt("").submit)
|
||||
self.assertRaises(QualityInspectionRequiredError, receipt("Scrap").submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
|
||||
)
|
||||
def test_secondary_item_allocation_uses_consumption_entry_cost(self):
|
||||
"""A BOM allocation splits the consumption entry's cost, not an empty set of consumed rows."""
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_from_wo,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
bom = frappe.get_doc(
|
||||
{
|
||||
"doctype": "BOM",
|
||||
"item": fg_item,
|
||||
"currency": "INR",
|
||||
"quantity": 10,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 10})
|
||||
bom.append(
|
||||
"secondary_items",
|
||||
{
|
||||
"type": "Scrap",
|
||||
"item_code": scrap_item,
|
||||
"item_name": scrap_item,
|
||||
"qty": 5,
|
||||
"cost_allocation_per": 25,
|
||||
"process_loss_per": 0,
|
||||
},
|
||||
)
|
||||
bom.insert()
|
||||
bom.submit()
|
||||
|
||||
make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100)
|
||||
wo = make_wo_order_test_record(
|
||||
production_item=fg_item, bom_no=bom.name, qty=10, skip_transfer=1, source_warehouse=warehouse
|
||||
)
|
||||
|
||||
consumption = frappe.get_doc(
|
||||
make_stock_entry_from_wo(wo.name, "Material Consumption for Manufacture", 10)
|
||||
)
|
||||
consumption.submit()
|
||||
self.assertEqual(flt(consumption.total_outgoing_value), 1000.0)
|
||||
|
||||
se = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
|
||||
se.save()
|
||||
|
||||
scrap_row = next(d for d in se.items if d.type)
|
||||
fg_row = next(d for d in se.items if d.is_finished_item)
|
||||
|
||||
self.assertEqual(flt(fg_row.basic_amount), 750.0)
|
||||
self.assertEqual(flt(scrap_row.basic_amount), 250.0)
|
||||
self.assertEqual(flt(se.total_incoming_value), 1000.0)
|
||||
|
||||
def _make_wo_for_free_raw_material(self, rm_item, fg_item, bom_no):
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
|
||||
@@ -6,6 +6,8 @@ from frappe.utils import today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.doctype.warehouse.warehouse import get_warehouses_based_on_account
|
||||
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
|
||||
create_reposting_entries,
|
||||
execute,
|
||||
@@ -55,3 +57,22 @@ class TestStockAndAccountValueComparison(ERPNextTestSuite):
|
||||
filters={"based_on": "Item and Warehouse", "item_code": item},
|
||||
)
|
||||
self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based")
|
||||
|
||||
def test_child_account_override_excluded_from_group_account(self):
|
||||
# A group warehouse carries an inventory account; a child (e.g. Goods-in-Transit) can override
|
||||
# it with its own account. get_warehouses_based_on_account must return only warehouses whose
|
||||
# effective account matches, excluding the overriding child.
|
||||
group = create_warehouse("_Test SAVC Group WH", {"is_group": 1}, company=PI_COMPANY)
|
||||
group_account = frappe.get_value("Warehouse", group, "account")
|
||||
|
||||
inheriting = create_warehouse(
|
||||
"_Test SAVC Inherit WH", {"parent_warehouse": group, "account": group_account}, company=PI_COMPANY
|
||||
)
|
||||
overriding = create_warehouse(
|
||||
"_Test SAVC Transit WH", {"parent_warehouse": group}, company=PI_COMPANY
|
||||
)
|
||||
|
||||
warehouses = get_warehouses_based_on_account(group_account, PI_COMPANY)
|
||||
|
||||
self.assertIn(inheriting, warehouses)
|
||||
self.assertNotIn(overriding, warehouses)
|
||||
|
||||
@@ -7,8 +7,10 @@ from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.utils import cint, flt, get_datetime
|
||||
from pypika import Order
|
||||
from pypika.analytics import RowNumber
|
||||
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
@@ -53,14 +55,15 @@ def execute(filters=None):
|
||||
|
||||
data = []
|
||||
conversion_factors = []
|
||||
if opening_row:
|
||||
data.append(opening_row)
|
||||
opening_rows = opening_row if isinstance(opening_row, list) else ([opening_row] if opening_row else [])
|
||||
for row in opening_rows:
|
||||
data.append(row)
|
||||
conversion_factors.append(0)
|
||||
|
||||
actual_qty = stock_value = 0
|
||||
if opening_row:
|
||||
actual_qty = opening_row.get("qty_after_transaction")
|
||||
stock_value = opening_row.get("stock_value")
|
||||
if opening_rows:
|
||||
actual_qty = opening_rows[0].get("qty_after_transaction", 0)
|
||||
stock_value = opening_rows[0].get("stock_value", 0)
|
||||
|
||||
available_serial_nos = {}
|
||||
|
||||
@@ -693,43 +696,120 @@ def get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value=N
|
||||
if not (filters.item_code and filters.warehouse and filters.from_date):
|
||||
return
|
||||
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
item_codes = filters.item_code
|
||||
if isinstance(item_codes, str):
|
||||
item_codes = [item_codes]
|
||||
|
||||
project = None
|
||||
if filters.get("project") and not frappe.get_all(
|
||||
"Inventory Dimension", filters={"reference_document": "Project"}
|
||||
):
|
||||
project = filters.get("project")
|
||||
warehouses = get_matching_warehouses(filters.warehouse)
|
||||
if not warehouses:
|
||||
return
|
||||
|
||||
last_entry = get_previous_sle(
|
||||
{
|
||||
"item_code": filters.item_code,
|
||||
"warehouse_condition": get_warehouse_condition(filters.warehouse),
|
||||
"posting_date": filters.from_date,
|
||||
"posting_time": "00:00:00",
|
||||
"project": project,
|
||||
},
|
||||
for_report=True,
|
||||
sle_doctype = frappe.qb.DocType("Stock Ledger Entry")
|
||||
sr_doctype = frappe.qb.DocType("Stock Reconciliation")
|
||||
|
||||
opening_reco_query = (
|
||||
frappe.qb.from_(sle_doctype)
|
||||
.inner_join(sr_doctype)
|
||||
.on(sle_doctype.voucher_no == sr_doctype.name)
|
||||
.select(sle_doctype.voucher_no)
|
||||
.where(sle_doctype.docstatus < 2)
|
||||
.where(sle_doctype.is_cancelled == 0)
|
||||
.where(sle_doctype.item_code.isin(item_codes))
|
||||
.where(sle_doctype.warehouse.isin(warehouses))
|
||||
.where(sle_doctype.voucher_type == "Stock Reconciliation")
|
||||
.where(sle_doctype.posting_date == filters.from_date)
|
||||
.where(sr_doctype.purpose == "Opening Stock")
|
||||
)
|
||||
|
||||
# check if any SLEs are actually Opening Stock Reconciliation
|
||||
for sle in list(sl_entries):
|
||||
if (
|
||||
sle.get("voucher_type") == "Stock Reconciliation"
|
||||
and sle.posting_date == filters.from_date
|
||||
and frappe.db.get_value("Stock Reconciliation", sle.voucher_no, "purpose") == "Opening Stock"
|
||||
):
|
||||
last_entry = sle
|
||||
sl_entries.remove(sle)
|
||||
opening_reco_vouchers = set(opening_reco_query.run(pluck=True))
|
||||
|
||||
row = {
|
||||
if opening_reco_vouchers:
|
||||
sl_entries[:] = [sle for sle in sl_entries if sle.get("voucher_no") not in opening_reco_vouchers]
|
||||
|
||||
sle_cond = (sle_doctype.posting_date < filters.from_date) | (
|
||||
(sle_doctype.posting_date == filters.from_date) & (sle_doctype.posting_time == "00:00:00")
|
||||
)
|
||||
if opening_reco_vouchers:
|
||||
sle_cond = sle_cond | (
|
||||
(sle_doctype.posting_date == filters.from_date)
|
||||
& (sle_doctype.voucher_no.isin(list(opening_reco_vouchers)))
|
||||
)
|
||||
|
||||
subq = (
|
||||
frappe.qb.from_(sle_doctype)
|
||||
.select(
|
||||
sle_doctype.qty_after_transaction,
|
||||
sle_doctype.stock_value,
|
||||
RowNumber()
|
||||
.over(sle_doctype.item_code, sle_doctype.warehouse)
|
||||
.orderby(sle_doctype.posting_datetime, sle_doctype.creation, sle_doctype.name, order=Order.desc)
|
||||
.as_("rn"),
|
||||
)
|
||||
.where(sle_doctype.docstatus < 2)
|
||||
.where(sle_doctype.is_cancelled == 0)
|
||||
.where(sle_doctype.item_code.isin(item_codes))
|
||||
.where(sle_doctype.warehouse.isin(warehouses))
|
||||
.where(sle_cond)
|
||||
)
|
||||
|
||||
for field in ["voucher_no", "project", "company"]:
|
||||
if filters.get(field):
|
||||
subq = subq.where(sle_doctype[field] == filters.get(field))
|
||||
|
||||
inventory_dimension_fields = get_inventory_dimension_fields()
|
||||
if inventory_dimension_fields:
|
||||
for fieldname in inventory_dimension_fields:
|
||||
if filters.get(fieldname):
|
||||
subq = subq.where(sle_doctype[fieldname].isin(filters.get(fieldname)))
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(subq)
|
||||
.select(
|
||||
IfNull(Sum(subq.qty_after_transaction), 0.0).as_("total_qty"),
|
||||
IfNull(Sum(subq.stock_value), 0.0).as_("total_stock_value"),
|
||||
)
|
||||
.where(subq.rn == 1)
|
||||
)
|
||||
|
||||
res = query.run(as_dict=True)
|
||||
|
||||
total_qty = flt(res[0].total_qty) if res else 0.0
|
||||
total_stock_value = flt(res[0].total_stock_value) if res else 0.0
|
||||
valuation_rate = flt(total_stock_value / total_qty) if total_qty else 0.0
|
||||
|
||||
return {
|
||||
"item_code": _("'Opening'"),
|
||||
"qty_after_transaction": last_entry.get("qty_after_transaction", 0),
|
||||
"valuation_rate": last_entry.get("valuation_rate", 0),
|
||||
"stock_value": last_entry.get("stock_value", 0),
|
||||
"qty_after_transaction": total_qty,
|
||||
"valuation_rate": valuation_rate,
|
||||
"stock_value": total_stock_value,
|
||||
}
|
||||
|
||||
return row
|
||||
|
||||
def get_matching_warehouses(warehouses):
|
||||
if not warehouses:
|
||||
return []
|
||||
|
||||
if isinstance(warehouses, str):
|
||||
warehouses = [warehouses]
|
||||
|
||||
warehouse_details = frappe.get_all(
|
||||
"Warehouse",
|
||||
filters={"name": ("in", warehouses)},
|
||||
fields=["lft", "rgt"],
|
||||
)
|
||||
|
||||
if not warehouse_details:
|
||||
return warehouses
|
||||
|
||||
wh = frappe.qb.DocType("Warehouse")
|
||||
cond = None
|
||||
for d in warehouse_details:
|
||||
c = (wh.lft >= d.lft) & (wh.rgt <= d.rgt)
|
||||
cond = c if cond is None else (cond | c)
|
||||
|
||||
matching = (frappe.qb.from_(wh).select(wh.name).where(cond)).run(pluck=True)
|
||||
|
||||
return matching if matching else warehouses
|
||||
|
||||
|
||||
def get_warehouse_condition(warehouses):
|
||||
@@ -785,7 +865,15 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value):
|
||||
if not filters.item_code or not filters.warehouse or not filters.from_date:
|
||||
return
|
||||
|
||||
if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1:
|
||||
item_codes = filters.get("item_code")
|
||||
if isinstance(item_codes, str):
|
||||
item_codes = [item_codes]
|
||||
|
||||
warehouses = filters.get("warehouse")
|
||||
if isinstance(warehouses, str):
|
||||
warehouses = [warehouses]
|
||||
|
||||
if len(item_codes) > 1 or len(warehouses) > 1:
|
||||
return
|
||||
|
||||
sl_doctype = frappe.qb.DocType("Stock Ledger Entry")
|
||||
@@ -805,17 +893,11 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value):
|
||||
)
|
||||
)
|
||||
|
||||
if filters.get("item_code"):
|
||||
if isinstance(filters.item_code, list | tuple):
|
||||
query = query.where(sl_doctype.item_code.isin(filters.item_code))
|
||||
else:
|
||||
query = query.where(sl_doctype.item_code == filters.item_code)
|
||||
if item_codes:
|
||||
query = query.where(sl_doctype.item_code.isin(item_codes))
|
||||
|
||||
if filters.get("warehouse"):
|
||||
if isinstance(filters.warehouse, list | tuple):
|
||||
query = query.where(sl_doctype.warehouse.isin(filters.warehouse))
|
||||
else:
|
||||
query = query.where(sl_doctype.warehouse == filters.warehouse)
|
||||
if warehouses:
|
||||
query = query.where(sl_doctype.warehouse.isin(warehouses))
|
||||
|
||||
for key, value in inv_dimension_wise_value.items():
|
||||
if isinstance(value, list | tuple):
|
||||
|
||||
@@ -4,18 +4,333 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
|
||||
make_serial_item_with_serial,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.report.stock_ledger.stock_ledger import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
WAREHOUSE = "Stores - _TC"
|
||||
|
||||
class TestStockLedgerReeport(ERPNextTestSuite):
|
||||
def setUp(self) -> None:
|
||||
make_serial_item_with_serial(self, "_Test Stock Report Serial Item")
|
||||
self.filters = frappe._dict(
|
||||
|
||||
class TestStockLedgerReport(ERPNextTestSuite):
|
||||
"""Correctness tests for the Stock Ledger report.
|
||||
|
||||
A shared `make_movements`/`run` pair keeps each test small without persisting
|
||||
any data: movements are created per test and rolled back, while the report runs
|
||||
read-only. Tests reuse bootstrap items and transact in `Stores - _TC`, which
|
||||
starts clean (zero balance) for these items.
|
||||
"""
|
||||
|
||||
def make_movements(self, item_code, movements):
|
||||
for movement in movements:
|
||||
make_stock_entry(item_code=item_code, **movement)
|
||||
|
||||
def run_report(self, item_code, from_date=None, to_date=None):
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
from_date=today(),
|
||||
to_date=add_days(today(), 30),
|
||||
item_code=["_Test Stock Report Serial Item"],
|
||||
from_date=from_date or add_days(today(), -1),
|
||||
to_date=to_date or today(),
|
||||
item_code=[item_code],
|
||||
warehouse=WAREHOUSE,
|
||||
)
|
||||
return list(execute(filters)[1])
|
||||
|
||||
def test_in_out_quantities_and_running_balance(self):
|
||||
item = "_Test Item"
|
||||
self.make_movements(
|
||||
item,
|
||||
[
|
||||
{"qty": 10, "to_warehouse": WAREHOUSE, "basic_rate": 100},
|
||||
{"qty": 4, "from_warehouse": WAREHOUSE},
|
||||
],
|
||||
)
|
||||
|
||||
rows = self.run_report(item)
|
||||
receipt = next(row for row in rows if row.get("in_qty"))
|
||||
issue = next(row for row in rows if row.get("out_qty"))
|
||||
|
||||
self.assertEqual(receipt["in_qty"], 10)
|
||||
self.assertEqual(receipt["qty_after_transaction"], 10)
|
||||
self.assertEqual(issue["out_qty"], -4)
|
||||
self.assertEqual(issue["qty_after_transaction"], 6)
|
||||
|
||||
def test_opening_balance_reflects_movements_before_from_date(self):
|
||||
item = "_Test Item"
|
||||
self.make_movements(
|
||||
item,
|
||||
[
|
||||
{
|
||||
"qty": 10,
|
||||
"to_warehouse": WAREHOUSE,
|
||||
"basic_rate": 100,
|
||||
"posting_date": add_days(today(), -10),
|
||||
},
|
||||
{"qty": 4, "from_warehouse": WAREHOUSE, "posting_date": today()},
|
||||
],
|
||||
)
|
||||
|
||||
rows = self.run_report(item, from_date=add_days(today(), -5), to_date=today())
|
||||
|
||||
# the receipt predates the range, so it surfaces as the opening balance
|
||||
self.assertEqual(rows[0]["item_code"], "'Opening'")
|
||||
self.assertEqual(rows[0]["qty_after_transaction"], 10)
|
||||
|
||||
# the in-range issue draws down from the opening balance
|
||||
issue = next(row for row in rows if row.get("out_qty"))
|
||||
self.assertEqual(issue["qty_after_transaction"], 6)
|
||||
|
||||
def test_filters_to_requested_item_only(self):
|
||||
item_a = "_Test Item"
|
||||
item_b = "_Test Item 2"
|
||||
self.make_movements(item_a, [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 100}])
|
||||
self.make_movements(item_b, [{"qty": 7, "to_warehouse": WAREHOUSE, "basic_rate": 100}])
|
||||
|
||||
rows = self.run_report(item_a)
|
||||
item_codes = {row["item_code"] for row in rows if row.get("voucher_no")}
|
||||
self.assertEqual(item_codes, {item_a})
|
||||
|
||||
def test_multi_item_opening_balance_with_and_without_transactions(self):
|
||||
item_a = "_Test Item"
|
||||
item_b = "_Test Item 2"
|
||||
self.make_movements(
|
||||
item_a,
|
||||
[
|
||||
{
|
||||
"qty": 10,
|
||||
"to_warehouse": WAREHOUSE,
|
||||
"basic_rate": 100,
|
||||
"posting_date": add_days(today(), -10),
|
||||
}
|
||||
],
|
||||
)
|
||||
self.make_movements(
|
||||
item_b,
|
||||
[{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 50, "posting_date": add_days(today(), -10)}],
|
||||
)
|
||||
self.make_movements(
|
||||
item_a,
|
||||
[{"qty": 2, "from_warehouse": WAREHOUSE, "posting_date": today()}],
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
from_date=add_days(today(), -5),
|
||||
to_date=today(),
|
||||
item_code=[item_a, item_b],
|
||||
warehouse=WAREHOUSE,
|
||||
)
|
||||
columns, rows = execute(filters)
|
||||
|
||||
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
|
||||
self.assertEqual(len(opening_rows), 1)
|
||||
self.assertEqual(opening_rows[0]["qty_after_transaction"], 15)
|
||||
|
||||
def test_multi_warehouse_opening_balance_aggregation(self):
|
||||
item = "_Test Item"
|
||||
warehouse_1 = "Stores - _TC"
|
||||
warehouse_2 = "Finished Goods - _TC"
|
||||
|
||||
self.make_movements(
|
||||
item,
|
||||
[
|
||||
{
|
||||
"qty": 10,
|
||||
"to_warehouse": warehouse_1,
|
||||
"basic_rate": 100,
|
||||
"posting_date": add_days(today(), -10),
|
||||
},
|
||||
{
|
||||
"qty": 20,
|
||||
"to_warehouse": warehouse_2,
|
||||
"basic_rate": 100,
|
||||
"posting_date": add_days(today(), -10),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
from_date=add_days(today(), -5),
|
||||
to_date=today(),
|
||||
item_code=[item],
|
||||
warehouse=[warehouse_1, warehouse_2],
|
||||
)
|
||||
columns, rows = execute(filters)
|
||||
|
||||
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
|
||||
self.assertEqual(len(opening_rows), 1)
|
||||
self.assertEqual(opening_rows[0]["qty_after_transaction"], 30)
|
||||
|
||||
def test_opening_stock_reconciliation_on_from_date_non_midnight_time(self):
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
item = "_Test Item"
|
||||
from_date = today()
|
||||
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item,
|
||||
warehouse=WAREHOUSE,
|
||||
qty=25,
|
||||
rate=100,
|
||||
posting_date=from_date,
|
||||
posting_time="10:30:00",
|
||||
purpose="Opening Stock",
|
||||
do_not_submit=False,
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
from_date=from_date,
|
||||
to_date=from_date,
|
||||
item_code=[item],
|
||||
warehouse=WAREHOUSE,
|
||||
)
|
||||
columns, rows = execute(filters)
|
||||
|
||||
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
|
||||
self.assertEqual(len(opening_rows), 1)
|
||||
self.assertEqual(opening_rows[0]["qty_after_transaction"], 25)
|
||||
|
||||
# Ensure the Opening Stock Reconciliation is not duplicated in detail transaction rows
|
||||
reco_rows = [row for row in rows if row.get("voucher_no") == sr.name]
|
||||
self.assertEqual(len(reco_rows), 0)
|
||||
|
||||
def test_backdated_sle_independent_maxima_handling(self):
|
||||
item = "_Test Item"
|
||||
# Entry 1: Later posting date (2026-07-20), created first
|
||||
self.make_movements(
|
||||
item,
|
||||
[
|
||||
{
|
||||
"qty": 10,
|
||||
"to_warehouse": WAREHOUSE,
|
||||
"basic_rate": 100,
|
||||
"posting_date": add_days(today(), -10),
|
||||
}
|
||||
],
|
||||
)
|
||||
# Entry 2: Backdated posting date (2026-07-15), created LATER
|
||||
self.make_movements(
|
||||
item,
|
||||
[
|
||||
{
|
||||
"qty": 5,
|
||||
"to_warehouse": WAREHOUSE,
|
||||
"basic_rate": 100,
|
||||
"posting_date": add_days(today(), -15),
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
from_date=add_days(today(), -5),
|
||||
to_date=today(),
|
||||
item_code=[item],
|
||||
warehouse=WAREHOUSE,
|
||||
)
|
||||
columns, rows = execute(filters)
|
||||
|
||||
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
|
||||
self.assertEqual(len(opening_rows), 1)
|
||||
# Should correctly pick the latest posting date entry (15 Qty) despite backdated creation order
|
||||
self.assertEqual(opening_rows[0]["qty_after_transaction"], 15)
|
||||
|
||||
def test_filtered_opening_balance_does_not_pick_excluded_creation_entry(self):
|
||||
item = "_Test Item"
|
||||
posting_date = add_days(today(), -10)
|
||||
posting_time = "09:00:00"
|
||||
|
||||
included_entry = make_stock_entry(
|
||||
item_code=item,
|
||||
qty=10,
|
||||
to_warehouse=WAREHOUSE,
|
||||
basic_rate=100,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
)
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
qty=50,
|
||||
to_warehouse=WAREHOUSE,
|
||||
basic_rate=100,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
from_date=add_days(today(), -5),
|
||||
to_date=today(),
|
||||
item_code=[item],
|
||||
warehouse=WAREHOUSE,
|
||||
voucher_no=included_entry.name,
|
||||
)
|
||||
columns, rows = execute(filters)
|
||||
|
||||
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
|
||||
self.assertEqual(len(opening_rows), 1)
|
||||
self.assertEqual(opening_rows[0]["qty_after_transaction"], 10)
|
||||
|
||||
def test_tied_creation_terminal_sle_is_not_summed_twice(self):
|
||||
item = "_Test Item"
|
||||
posting_date = add_days(today(), -10)
|
||||
posting_time = "09:00:00"
|
||||
|
||||
stock_entry_1 = make_stock_entry(
|
||||
item_code=item,
|
||||
qty=10,
|
||||
to_warehouse=WAREHOUSE,
|
||||
basic_rate=100,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
)
|
||||
stock_entry_2 = make_stock_entry(
|
||||
item_code=item,
|
||||
qty=5,
|
||||
to_warehouse=WAREHOUSE,
|
||||
basic_rate=100,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
)
|
||||
|
||||
sle_rows = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={
|
||||
"voucher_type": "Stock Entry",
|
||||
"voucher_no": ("in", [stock_entry_1.name, stock_entry_2.name]),
|
||||
"item_code": item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fields=["name", "qty_after_transaction"],
|
||||
order_by="name desc",
|
||||
)
|
||||
self.assertEqual(len(sle_rows), 2)
|
||||
|
||||
for sle in sle_rows:
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
sle.name,
|
||||
"creation",
|
||||
"2026-01-01 00:00:00.000000",
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
from_date=add_days(today(), -5),
|
||||
to_date=today(),
|
||||
item_code=[item],
|
||||
warehouse=WAREHOUSE,
|
||||
)
|
||||
columns, rows = execute(filters)
|
||||
|
||||
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
|
||||
self.assertEqual(len(opening_rows), 1)
|
||||
self.assertEqual(opening_rows[0]["qty_after_transaction"], sle_rows[0].qty_after_transaction)
|
||||
self.assertNotEqual(
|
||||
opening_rows[0]["qty_after_transaction"],
|
||||
sum(sle.qty_after_transaction for sle in sle_rows),
|
||||
)
|
||||
|
||||
@@ -820,13 +820,14 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
"Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "total_amount"
|
||||
)
|
||||
else:
|
||||
entries = self.get_batch_stock_before_date()
|
||||
self.stock_value_change = 0.0
|
||||
self.batch_avg_rate = defaultdict(float)
|
||||
self.available_qty = defaultdict(float)
|
||||
self.stock_value_differece = defaultdict(float)
|
||||
|
||||
for ledger in entries:
|
||||
self.seed_from_stock_closing_balance()
|
||||
|
||||
for ledger in self.get_batch_stock_before_date():
|
||||
self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate)
|
||||
self.available_qty[ledger.batch_no] += flt(ledger.qty)
|
||||
|
||||
@@ -834,6 +835,52 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
self.calculate_avg_rate_for_non_batchwise_valuation()
|
||||
self.set_stock_value_difference()
|
||||
|
||||
def seed_from_stock_closing_balance(self):
|
||||
self.stock_closing_from_datetime = None
|
||||
closing_entry = self.get_closing_entry_for_seeding()
|
||||
if not closing_entry:
|
||||
return
|
||||
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
self.stock_closing_from_datetime = get_combine_datetime(
|
||||
add_days(closing_entry.to_date, 1), "00:00:00"
|
||||
)
|
||||
|
||||
for row in self.get_stock_closing_balance_entries(closing_entry.name):
|
||||
self.stock_value_differece[row.batch_no] += flt(row.stock_value_difference)
|
||||
self.available_qty[row.batch_no] += flt(row.actual_qty)
|
||||
|
||||
def get_closing_entry_for_seeding(self):
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import (
|
||||
get_closing_entry_for_closed_period,
|
||||
)
|
||||
|
||||
if not self.batchwise_valuation_batches or not self.sle.posting_date:
|
||||
return None
|
||||
|
||||
company = self.sle.company or frappe.get_cached_value("Warehouse", self.sle.warehouse, "company")
|
||||
closing_entry = get_closing_entry_for_closed_period(company)
|
||||
if not closing_entry or getdate(self.sle.posting_date) <= getdate(closing_entry.to_date):
|
||||
return None
|
||||
|
||||
return closing_entry
|
||||
|
||||
def get_stock_closing_balance_entries(self, closing_entry):
|
||||
table = frappe.qb.DocType("Stock Closing Balance")
|
||||
|
||||
return (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.batch_no, table.actual_qty, table.stock_value_difference)
|
||||
.where(
|
||||
(table.stock_closing_entry == closing_entry)
|
||||
& (table.item_code == self.sle.item_code)
|
||||
& (table.warehouse == self.sle.warehouse)
|
||||
& table.batch_no.isin(self.batchwise_valuation_batches)
|
||||
& (table.inventory_dimension_key.isnull() | (table.inventory_dimension_key == ""))
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
def get_batch_stock_before_date(self) -> list[dict]:
|
||||
# Get batch wise stock value difference from Serial and Batch Bundle considering time condition
|
||||
if not self.batchwise_valuation_batches:
|
||||
@@ -841,14 +888,45 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
|
||||
child = frappe.qb.DocType("Serial and Batch Entry")
|
||||
|
||||
sle_creation = self.sle.creation if self.sle.get("name") else None
|
||||
if not self.sle.get("name") and self.sle.get("serial_and_batch_bundle"):
|
||||
sle_creation = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"serial_and_batch_bundle": self.sle.serial_and_batch_bundle, "is_cancelled": 0},
|
||||
"creation",
|
||||
)
|
||||
|
||||
timestamp_condition = ""
|
||||
if self.sle.posting_datetime:
|
||||
timestamp_condition = child.posting_datetime < self.sle.posting_datetime
|
||||
|
||||
if self.sle.creation:
|
||||
timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & (
|
||||
child.creation < self.sle.creation
|
||||
sle_table = frappe.qb.DocType("Stock Ledger Entry")
|
||||
if sle_creation:
|
||||
# bundle creation and SLE creation are different timelines (a
|
||||
# bundle can be created much before its SLE), so break the tie
|
||||
# using the creation of the bundle's own SLE
|
||||
tie_condition = ExistsCriterion(
|
||||
frappe.qb.from_(sle_table)
|
||||
.select(sle_table.name)
|
||||
.where(
|
||||
(sle_table.serial_and_batch_bundle == child.parent)
|
||||
& (sle_table.is_cancelled == 0)
|
||||
& (sle_table.creation < sle_creation)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# the current entry is not yet in the ledger and will get the
|
||||
# latest creation, so the same-timestamp entries which are
|
||||
# already in the ledger precede it
|
||||
tie_condition = ExistsCriterion(
|
||||
frappe.qb.from_(sle_table)
|
||||
.select(sle_table.name)
|
||||
.where(
|
||||
(sle_table.serial_and_batch_bundle == child.parent) & (sle_table.is_cancelled == 0)
|
||||
)
|
||||
)
|
||||
|
||||
timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & tie_condition
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(child)
|
||||
@@ -878,6 +956,9 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
if timestamp_condition:
|
||||
query = query.where(timestamp_condition)
|
||||
|
||||
if self.stock_closing_from_datetime:
|
||||
query = query.where(child.posting_datetime >= self.stock_closing_from_datetime)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
def prepare_batches(self):
|
||||
@@ -890,6 +971,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
self.batchwise_valuation_batches = []
|
||||
self.non_batchwise_valuation_batches = []
|
||||
|
||||
if batchwise_batches := self.sle.get("batchwise_valuation_batches"):
|
||||
self.batchwise_valuation_batches = list(batchwise_batches)
|
||||
self.non_batchwise_valuation_batches = list(set(self.batches) - set(batchwise_batches))
|
||||
return
|
||||
|
||||
if get_valuation_method(
|
||||
self.sle.item_code, self.sle.company
|
||||
) == "Moving Average" and frappe.get_single_value("Stock Settings", "do_not_use_batchwise_valuation"):
|
||||
|
||||
@@ -56,6 +56,32 @@ class SerialNoExistsInFutureTransaction(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_stock_frozen_by_closing_entry(sl_entries):
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import (
|
||||
get_closing_entry_for_closed_period,
|
||||
)
|
||||
|
||||
company = sl_entries[0].get("company")
|
||||
if not company:
|
||||
company = frappe.get_cached_value("Warehouse", sl_entries[0].get("warehouse"), "company")
|
||||
|
||||
closing_entry = get_closing_entry_for_closed_period(company)
|
||||
if not closing_entry:
|
||||
return
|
||||
|
||||
for sle in sl_entries:
|
||||
if sle.get("posting_date") and getdate(sle.get("posting_date")) <= getdate(closing_entry.to_date):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Stock transactions dated on or before {0} are frozen because the period is closed and the Stock Closing Entry {1} has been generated. To make changes, cancel the Period Closing Voucher first."
|
||||
).format(
|
||||
frappe.bold(format_date(closing_entry.to_date)),
|
||||
get_link_to_form("Stock Closing Entry", closing_entry.name),
|
||||
),
|
||||
title=_("Stock Frozen"),
|
||||
)
|
||||
|
||||
|
||||
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
|
||||
"""Create SL entries from SL entry dicts
|
||||
|
||||
@@ -70,6 +96,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
|
||||
from erpnext.controllers.stock_controller import future_sle_exists
|
||||
|
||||
if sl_entries:
|
||||
validate_stock_frozen_by_closing_entry(sl_entries)
|
||||
|
||||
cancelled = sl_entries[0].get("is_cancelled")
|
||||
if cancelled:
|
||||
validate_cancellation(sl_entries)
|
||||
|
||||
Reference in New Issue
Block a user