mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-06 03:03:04 +00:00
Compare commits
1 Commits
develop
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
765018c08e |
@@ -3449,9 +3449,9 @@ socket.io-client@4.7.1:
|
||||
socket.io-parser "~4.2.4"
|
||||
|
||||
socket.io-parser@~4.2.4:
|
||||
version "4.2.6"
|
||||
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.6.tgz#19156bf179af3931abd05260cfb1491822578a6f"
|
||||
integrity sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==
|
||||
version "4.2.7"
|
||||
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz#679e51fe24d1c81df90fc5f7efe4a5f432fe99c0"
|
||||
integrity sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==
|
||||
dependencies:
|
||||
"@socket.io/component-emitter" "~3.1.0"
|
||||
debug "~4.4.1"
|
||||
|
||||
@@ -6,10 +6,8 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
|
||||
from frappe.utils import add_days, flt, formatdate, getdate
|
||||
|
||||
from erpnext import is_perpetual_inventory_enabled
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
make_closing_entries,
|
||||
)
|
||||
@@ -19,8 +17,6 @@ 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):
|
||||
@@ -145,121 +141,6 @@ 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"):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, today
|
||||
from frappe.utils import 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
|
||||
@@ -386,218 +386,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertEqual(acb_figures["Cash"][key_for(cc1)], 400)
|
||||
self.assertEqual(acb_figures["Cash"][key_for(cc2)], 200)
|
||||
|
||||
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")
|
||||
|
||||
@@ -587,12 +587,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
super.set_dynamic_labels();
|
||||
this.frm.events.hide_fields(this.frm);
|
||||
const hide_update_stock = cint(this.frm.doc.is_debit_note) || cint(this.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
|
||||
);
|
||||
this.frm.set_df_property("update_stock", "hidden", hide_update_stock || hidden_by_customization);
|
||||
this.frm.set_df_property("update_stock", "hidden", hide_update_stock);
|
||||
}
|
||||
|
||||
items_on_form_rendered() {
|
||||
|
||||
3354
erpnext/locale/ar.po
3354
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/bg.po
3342
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
3466
erpnext/locale/bs.po
3466
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/cs.po
3342
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
3372
erpnext/locale/da.po
3372
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3362
erpnext/locale/de.po
3362
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3374
erpnext/locale/eo.po
3374
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3362
erpnext/locale/es.po
3362
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
3466
erpnext/locale/fa.po
3466
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
3352
erpnext/locale/fr.po
3352
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
3350
erpnext/locale/hi.po
3350
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3444
erpnext/locale/hr.po
3444
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/hu.po
3342
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
3348
erpnext/locale/id.po
3348
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
3358
erpnext/locale/it.po
3358
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
3350
erpnext/locale/ko.po
3350
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/my.po
3342
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/nb.po
3342
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
3362
erpnext/locale/nl.po
3362
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
3348
erpnext/locale/pl.po
3348
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/pt.po
3342
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
64457
erpnext/locale/ro.po
64457
erpnext/locale/ro.po
File diff suppressed because it is too large
Load Diff
3366
erpnext/locale/ru.po
3366
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
3598
erpnext/locale/sl.po
3598
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
3362
erpnext/locale/sr.po
3362
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3378
erpnext/locale/sv.po
3378
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
3362
erpnext/locale/th.po
3362
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
3356
erpnext/locale/tr.po
3356
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
3368
erpnext/locale/uz.po
3368
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
3362
erpnext/locale/vi.po
3362
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
19761
erpnext/locale/zh.po
19761
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -120,8 +120,8 @@ class BlanketOrder(Document):
|
||||
|
||||
def validate_item_qty(self):
|
||||
for d in self.items:
|
||||
if flt(d.qty) <= 0:
|
||||
frappe.throw(_("Row {0}: Quantity must be greater than zero.").format(d.idx))
|
||||
if flt(d.qty) < 0:
|
||||
frappe.throw(_("Row {0}: Quantity cannot be negative.").format(d.idx))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -149,11 +149,7 @@ def make_order(source_name: str):
|
||||
"Blanket Order",
|
||||
source_name,
|
||||
{
|
||||
"Blanket Order": {
|
||||
"doctype": doctype,
|
||||
"field_no_map": ["naming_series"],
|
||||
"postprocess": update_doc,
|
||||
},
|
||||
"Blanket Order": {"doctype": doctype, "postprocess": update_doc},
|
||||
"Blanket Order Item": {
|
||||
"doctype": doctype + " Item",
|
||||
"field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"},
|
||||
|
||||
@@ -25,7 +25,6 @@ class TestBlanketOrder(ERPNextTestSuite):
|
||||
so.submit()
|
||||
|
||||
self.assertEqual(so.doctype, "Sales Order")
|
||||
self.assertNotEqual(so.naming_series, bo.naming_series)
|
||||
self.assertEqual(len(so.get("items")), len(bo.get("items")))
|
||||
|
||||
# check the rate, quantity and updation for the ordered quantity
|
||||
@@ -51,7 +50,6 @@ class TestBlanketOrder(ERPNextTestSuite):
|
||||
po.submit()
|
||||
|
||||
self.assertEqual(po.doctype, "Purchase Order")
|
||||
self.assertNotEqual(po.naming_series, bo.naming_series)
|
||||
self.assertEqual(len(po.get("items")), len(bo.get("items")))
|
||||
|
||||
# check the rate, quantity and updation for the ordered quantity
|
||||
@@ -164,26 +162,6 @@ class TestBlanketOrder(ERPNextTestSuite):
|
||||
bo = make_blanket_order(blanket_order_type="Purchasing", supplier=supplier, item_code=item_code)
|
||||
self.assertEqual(bo.items[0].party_item_code, "SUPP-PART-1")
|
||||
|
||||
def test_blanket_order_zero_quantity(self):
|
||||
bo = frappe.new_doc("Blanket Order")
|
||||
bo.blanket_order_type = "Selling"
|
||||
bo.company = "_Test Company"
|
||||
bo.customer = "_Test Customer"
|
||||
bo.from_date = today()
|
||||
bo.to_date = add_months(today(), 12)
|
||||
|
||||
bo.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 0,
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
bo.insert()
|
||||
|
||||
|
||||
def make_blanket_order(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
@@ -145,9 +145,6 @@ class DeprecatedBatchNoValuation:
|
||||
if self.sle.name:
|
||||
conditions &= sle.name != self.sle.name
|
||||
|
||||
if getattr(self, "stock_closing_from_datetime", None):
|
||||
conditions &= sle.posting_datetime >= self.stock_closing_from_datetime
|
||||
|
||||
# MariaDB carries a row lock on the grouped query below; on postgres the caller
|
||||
# (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse).
|
||||
query = (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, add_to_date, flt, nowtime, today
|
||||
from frappe.utils import 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 (
|
||||
@@ -1637,190 +1637,3 @@ 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))
|
||||
|
||||
@@ -9,51 +9,9 @@ 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
|
||||
@@ -108,7 +66,7 @@ class StockClosingEntry(Document):
|
||||
)
|
||||
)
|
||||
|
||||
for fieldname in SCOPE_FIELDS:
|
||||
for fieldname in ["warehouse", "item_code", "item_group", "warehouse_type"]:
|
||||
if self.get(fieldname):
|
||||
query = query.where(table[fieldname] == self.get(fieldname))
|
||||
|
||||
@@ -126,30 +84,14 @@ 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(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def enqueue_job(self):
|
||||
self.db_set("status", "In Progress")
|
||||
enqueue(prepare_closing_stock_balance, name=self.name, queue="long", timeout=1500)
|
||||
@@ -159,9 +101,8 @@ class StockClosingEntry(Document):
|
||||
).format(self.name)
|
||||
)
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def regenerate_closing_balance(self):
|
||||
self.validate_closed_period_lock()
|
||||
self.remove_stock_closing()
|
||||
self.enqueue_job()
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from frappe.model.naming import NamingSeries, parse_naming_series
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, now
|
||||
from pypika import Order
|
||||
from pypika.terms import ExistsCriterion
|
||||
|
||||
from erpnext.stock.deprecated_serial_batch import (
|
||||
DeprecatedBatchNoValuation,
|
||||
@@ -831,14 +830,13 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
("batch-valuation", self.sle.item_code, self.sle.warehouse)
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
self.seed_from_stock_closing_balance()
|
||||
|
||||
for ledger in self.get_batch_stock_before_date():
|
||||
for ledger in entries:
|
||||
self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate)
|
||||
self.available_qty[ledger.batch_no] += flt(ledger.qty)
|
||||
|
||||
@@ -846,52 +844,6 @@ 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:
|
||||
@@ -899,45 +851,14 @@ 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
|
||||
|
||||
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)
|
||||
)
|
||||
if self.sle.creation:
|
||||
timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & (
|
||||
child.creation < self.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
|
||||
|
||||
conditions = (
|
||||
(child.item_code == self.sle.item_code)
|
||||
@@ -957,9 +878,6 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
if timestamp_condition:
|
||||
conditions &= timestamp_condition
|
||||
|
||||
if self.stock_closing_from_datetime:
|
||||
conditions &= child.posting_datetime >= self.stock_closing_from_datetime
|
||||
|
||||
# MariaDB carries a row lock on the grouped query below; on postgres the caller
|
||||
# (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse)
|
||||
# instead of row-locking the whole history (FOR UPDATE is invalid with GROUP BY there).
|
||||
|
||||
@@ -101,32 +101,6 @@ def validate_standard_cost_posting_date(sl_entries):
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -145,8 +119,6 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
|
||||
for pair in sorted({(d.get("item_code"), d.get("warehouse")) for d in sl_entries}):
|
||||
sle_processing_gate(*pair)
|
||||
|
||||
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