mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-22 12:57:14 +00:00
Compare commits
22 Commits
rejected-m
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf3bc709f | ||
|
|
102842253b | ||
|
|
e814d13126 | ||
|
|
7f608f31eb | ||
|
|
f990df9deb | ||
|
|
62cb38d36b | ||
|
|
8d6ee86ba3 | ||
|
|
e3fc1790d4 | ||
|
|
719b53f2ad | ||
|
|
79fd06a1ba | ||
|
|
5ebee655b7 | ||
|
|
9f2b05c103 | ||
|
|
9e01992217 | ||
|
|
5ebc52c95c | ||
|
|
86eb9c6204 | ||
|
|
893fc75df4 | ||
|
|
f2279a139f | ||
|
|
30e0382aa9 | ||
|
|
d2b1965cc8 | ||
|
|
a931c7d2f4 | ||
|
|
221baabf9e | ||
|
|
8b59d66f94 |
@@ -47,10 +47,13 @@ class ERPNextAddress(Address):
|
||||
super().on_update()
|
||||
|
||||
address_display = get_address_display(self.as_dict())
|
||||
filters = {"customer_primary_address": self.name}
|
||||
customers = frappe.db.get_all("Customer", filters=filters, as_list=True)
|
||||
for customer_name in customers:
|
||||
frappe.db.set_value("Customer", customer_name[0], "primary_address", address_display)
|
||||
customers = frappe.db.get_all(
|
||||
"Customer", filters={"customer_primary_address": self.name}, pluck="name"
|
||||
)
|
||||
for customer in customers:
|
||||
frappe.db.set_value(
|
||||
"Customer", customer, "primary_address", address_display, update_modified=False
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -253,6 +253,10 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Docume
|
||||
|
||||
def post_process(source, target) -> None:
|
||||
target.reversal_of = source.name
|
||||
target.naming_series = source.naming_series
|
||||
if source.voucher_type == "Bank Entry":
|
||||
target.cheque_no = source.cheque_no
|
||||
target.cheque_date = source.cheque_date
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
|
||||
@@ -280,12 +280,13 @@ class PeriodClosingVoucher(AccountsController):
|
||||
|
||||
data = self.get_data_for_mapreduce()
|
||||
mapreduce(
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.mapper",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.process_date_range",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.aggregate_partial_result",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.summarize_and_post_ledger",
|
||||
data,
|
||||
self.doctype,
|
||||
self.name,
|
||||
f"Closing FY {self.fiscal_year}",
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
@@ -330,8 +331,8 @@ class PeriodClosingVoucher(AccountsController):
|
||||
|
||||
def make_gl_entries(self):
|
||||
if frappe.db.estimate_count("GL Entry") > 100_000:
|
||||
frappe.enqueue(
|
||||
process_gl_and_closing_entries,
|
||||
frappe.enqueue_task(
|
||||
method=process_gl_and_closing_entries,
|
||||
doc=self,
|
||||
timeout=1800,
|
||||
)
|
||||
@@ -834,7 +835,7 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
|
||||
return prev_closed_period_end_date
|
||||
|
||||
|
||||
def mapper(val):
|
||||
def process_date_range(val):
|
||||
start_date = val.from_date
|
||||
end_date = val.to_date
|
||||
pcv = val.pcv
|
||||
@@ -881,7 +882,7 @@ def mapper(val):
|
||||
return res
|
||||
|
||||
|
||||
def reducer(final, partial_res):
|
||||
def aggregate_partial_result(final, partial_res):
|
||||
if final is None:
|
||||
final = []
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
frappe.listview_settings["Period Closing Voucher"] = {
|
||||
add_fields: ["gle_processing_status"],
|
||||
get_indicator: function (doc) {
|
||||
const status_colors = {
|
||||
Draft: "red",
|
||||
Submitted: "blue",
|
||||
Cancelled: "red",
|
||||
};
|
||||
|
||||
const gle_processing_status = {
|
||||
"In Progress": [__("Processing GL Entries"), "blue"],
|
||||
Completed: [__("Period Closed"), "green"],
|
||||
Failed: [__("Period Closing Failed"), "red"],
|
||||
};
|
||||
if (doc.docstatus != 0) {
|
||||
return [
|
||||
gle_processing_status[doc.gle_processing_status][0],
|
||||
gle_processing_status[doc.gle_processing_status][1],
|
||||
"gle_processing_status,=," + doc.gle_processing_status,
|
||||
];
|
||||
}
|
||||
|
||||
return [__(doc.docstatus), status_colors[doc.docstatus], "docstatus,=," + doc.docstatus];
|
||||
},
|
||||
};
|
||||
@@ -6,8 +6,9 @@ from datetime import date
|
||||
import frappe
|
||||
from frappe import _, msgprint, qb, scrub
|
||||
from frappe.contacts.doctype.address.address import get_company_address, get_default_address
|
||||
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
|
||||
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
|
||||
from frappe.model.utils import get_fetch_values
|
||||
from frappe.permissions import get_allowed_docs_for_doctype
|
||||
from frappe.query_builder.functions import Abs, Date, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
@@ -159,7 +160,7 @@ def _get_party_details(
|
||||
)
|
||||
set_contact_details(party_details, party, party_type, doctype)
|
||||
set_other_values(party_details, party, party_type)
|
||||
set_price_list(party_details, party, party_type, price_list, pos_profile)
|
||||
set_price_list(party_details, party, party_type, price_list, pos_profile, doctype)
|
||||
|
||||
tax_template = set_taxes(
|
||||
party.name,
|
||||
@@ -408,13 +409,33 @@ def get_default_price_list(party):
|
||||
return price_list
|
||||
|
||||
|
||||
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
|
||||
def get_permitted_price_lists(doctype=None):
|
||||
permissions = sorted(
|
||||
get_user_permissions().get("Price List", []), key=lambda p: p.get("is_default"), reverse=True
|
||||
)
|
||||
|
||||
# a permission applicable for another doctype doesn't restrict this transaction
|
||||
return get_allowed_docs_for_doctype(permissions, doctype)
|
||||
|
||||
|
||||
def get_usable_price_list(price_lists, party_doctype):
|
||||
transaction_side = "selling" if party_doctype == "Customer" else "buying"
|
||||
|
||||
for price_list in price_lists:
|
||||
details = frappe.get_cached_value(
|
||||
"Price List", price_list, ["enabled", transaction_side], as_dict=True
|
||||
)
|
||||
if details.enabled and details[transaction_side]:
|
||||
return price_list
|
||||
|
||||
|
||||
def set_price_list(party_details, party, party_type, given_price_list, pos=None, doctype=None):
|
||||
# price list
|
||||
price_list = get_permitted_documents("Price List")
|
||||
permitted_price_lists = get_permitted_price_lists(doctype)
|
||||
|
||||
# if there is only one permitted document based on user permissions, set it
|
||||
if price_list and len(price_list) == 1:
|
||||
price_list = price_list[0]
|
||||
if len(permitted_price_lists) == 1:
|
||||
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
|
||||
elif pos and party_type == "Customer":
|
||||
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
|
||||
|
||||
@@ -426,6 +447,10 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
|
||||
else:
|
||||
price_list = get_default_price_list(party) or given_price_list
|
||||
|
||||
# don't set a price list the user has no permission for, the transaction can't be saved with it
|
||||
if price_list and permitted_price_lists and price_list not in permitted_price_lists:
|
||||
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
|
||||
|
||||
if price_list and not is_price_list_enabled(price_list):
|
||||
price_list = None
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.accounts.party import get_default_price_list, set_price_list
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -34,19 +35,159 @@ class PartyTestCase(ERPNextTestSuite):
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def create_price_list(self, enabled):
|
||||
def test_fallback_should_not_pick_an_unpermitted_price_list(self):
|
||||
permitted_default = self.create_price_list(enabled=1)
|
||||
permitted_other = self.create_price_list(enabled=1)
|
||||
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(
|
||||
party_details, customer, "Customer", self.create_price_list(enabled=1), doctype="Sales Order"
|
||||
)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, permitted_default)
|
||||
|
||||
def test_permitted_given_price_list_should_be_kept(self):
|
||||
permitted_default = self.create_price_list(enabled=1)
|
||||
permitted_other = self.create_price_list(enabled=1)
|
||||
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", permitted_other, doctype="Sales Order")
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, permitted_other)
|
||||
|
||||
def test_permission_for_another_doctype_should_not_apply(self):
|
||||
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
|
||||
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, given_price_list)
|
||||
|
||||
def test_a_single_permitted_price_list_should_fit_the_transaction(self):
|
||||
buying_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
|
||||
user = self.create_user_with_price_list_permissions([buying_price_list])
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def test_buying_transaction_should_not_take_a_selling_price_list(self):
|
||||
permitted = [self.create_price_list(enabled=1) for _ in range(2)]
|
||||
user = self.create_user_with_price_list_permissions(permitted)
|
||||
supplier_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
|
||||
supplier = self.create_supplier(default_price_list=supplier_price_list)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, supplier, "Supplier", None, doctype="Purchase Order")
|
||||
|
||||
self.assertIsNone(party_details.buying_price_list)
|
||||
|
||||
def test_permission_for_another_doctype_should_not_apply_without_a_doctype(self):
|
||||
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
|
||||
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, given_price_list)
|
||||
|
||||
def test_pos_price_list_should_be_kept(self):
|
||||
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
|
||||
user = self.create_user_with_price_list_permissions(permitted)
|
||||
pos_price_list = self.create_price_list(enabled=1)
|
||||
pos_profile = make_pos_profile(selling_price_list=pos_price_list)
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(
|
||||
party_details, customer, "Customer", None, pos=pos_profile.name, doctype="POS Invoice"
|
||||
)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, pos_price_list)
|
||||
|
||||
def test_disabled_permitted_price_lists_should_clear_the_price_list(self):
|
||||
permitted = [self.create_price_list(enabled=0), self.create_price_list(enabled=0)]
|
||||
user = self.create_user_with_price_list_permissions(permitted)
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def create_user_with_price_list_permissions(self, price_lists, applicable_for=None):
|
||||
user = frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": f"{frappe.generate_hash(length=10)}@example.com",
|
||||
"first_name": "Price List Test",
|
||||
"send_welcome_email": 0,
|
||||
"roles": [{"role": "Sales User"}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
for idx, price_list in enumerate(price_lists):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User Permission",
|
||||
"user": user.name,
|
||||
"allow": "Price List",
|
||||
"for_value": price_list,
|
||||
"is_default": int(idx == 0),
|
||||
"apply_to_all_doctypes": int(not applicable_for),
|
||||
"applicable_for": applicable_for,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.clear_cache(user=user.name)
|
||||
self.addCleanup(frappe.clear_cache, user=user.name)
|
||||
|
||||
return user.name
|
||||
|
||||
def create_price_list(self, enabled, selling=1, buying=0):
|
||||
price_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": 1,
|
||||
"selling": selling,
|
||||
"buying": buying,
|
||||
"enabled": enabled,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return price_list.name
|
||||
|
||||
def create_supplier(self, **values):
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier",
|
||||
"supplier_name": frappe.generate_hash(length=10),
|
||||
**values,
|
||||
}
|
||||
).insert(ignore_permissions=True, ignore_mandatory=True)
|
||||
|
||||
def create_customer(self, **values):
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Asset",
|
||||
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"],[\"Asset\",\"company\",\"=\",\"eval:doc.company\"]]",
|
||||
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"]]",
|
||||
"options": "Asset",
|
||||
"reqd": 1
|
||||
},
|
||||
|
||||
@@ -349,12 +349,26 @@ class AccountsController(TransactionBase):
|
||||
self.validate_company_in_accounting_dimension()
|
||||
|
||||
def validate_price_list(self):
|
||||
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
|
||||
if self.get("selling_price_list"):
|
||||
price_list_field, transaction_side = "selling_price_list", "selling"
|
||||
else:
|
||||
price_list_field, transaction_side = "buying_price_list", "buying"
|
||||
|
||||
price_list = self.get(price_list_field)
|
||||
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
|
||||
if not price_list:
|
||||
return
|
||||
|
||||
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
|
||||
details = (
|
||||
frappe.db.get_value("Price List", price_list, ["enabled", transaction_side], as_dict=True)
|
||||
or frappe._dict()
|
||||
)
|
||||
|
||||
# An internal transfer carries the price list of the outward document into the inward one.
|
||||
fits_transaction = details.get(transaction_side) or self.is_internal_transfer()
|
||||
if details.enabled and fits_transaction:
|
||||
return
|
||||
|
||||
# Returns retain a submitted voucher's pricing even if its price list no longer fits.
|
||||
if (
|
||||
self.get("is_return")
|
||||
and self.get("return_against")
|
||||
@@ -365,9 +379,20 @@ class AccountsController(TransactionBase):
|
||||
):
|
||||
return
|
||||
|
||||
if not details.enabled:
|
||||
frappe.throw(
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
)
|
||||
|
||||
if transaction_side == "selling":
|
||||
message = _("Price List {0} cannot be used on a selling transaction")
|
||||
else:
|
||||
message = _("Price List {0} cannot be used on a buying transaction")
|
||||
|
||||
frappe.throw(
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
message.format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Invalid Price List"),
|
||||
)
|
||||
|
||||
def set_default_letter_head(self):
|
||||
@@ -724,12 +749,15 @@ class AccountsController(TransactionBase):
|
||||
args = "for_buying"
|
||||
|
||||
if self.meta.get_field(fieldname) and self.get(fieldname):
|
||||
previous_price_list_currency = self.price_list_currency
|
||||
self.price_list_currency = frappe.db.get_value("Price List", self.get(fieldname), "currency")
|
||||
|
||||
if self.price_list_currency == self.company_currency:
|
||||
self.plc_conversion_rate = 1.0
|
||||
|
||||
elif not self.plc_conversion_rate:
|
||||
elif not self.plc_conversion_rate or (
|
||||
previous_price_list_currency and previous_price_list_currency != self.price_list_currency
|
||||
):
|
||||
self.plc_conversion_rate = get_exchange_rate(
|
||||
self.price_list_currency, self.company_currency, transaction_date, args
|
||||
)
|
||||
|
||||
59
erpnext/controllers/tests/test_price_list_currency.py
Normal file
59
erpnext/controllers/tests/test_price_list_currency.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
|
||||
class TestPriceListCurrency(UnitTestCase):
|
||||
def test_price_list_currency_transition(self):
|
||||
cases = (
|
||||
("USD", "CDF", 1, 0.000444444, True),
|
||||
("EUR", "CDF", 1.2, 0.000444444, True),
|
||||
("CDF", "USD", 0.000444444, 1, False),
|
||||
("CDF", "CDF", 0.0005, 0.0005, False),
|
||||
("CDF", "CDF", 0, 0.000444444, True),
|
||||
(None, "CDF", 0.0005, 0.0005, False),
|
||||
)
|
||||
for direction in ("Selling", "Buying"):
|
||||
for previous_currency, currency, previous_rate, expected_rate, fetch_rate in cases:
|
||||
with self.subTest(
|
||||
direction=direction,
|
||||
previous_currency=previous_currency,
|
||||
currency=currency,
|
||||
previous_rate=previous_rate,
|
||||
):
|
||||
doc = frappe._dict(
|
||||
meta=Mock(),
|
||||
posting_date="2026-09-18",
|
||||
selling_price_list="New Selling Price List",
|
||||
buying_price_list="New Buying Price List",
|
||||
price_list_currency=previous_currency,
|
||||
plc_conversion_rate=previous_rate,
|
||||
company_currency="USD",
|
||||
currency="CDF",
|
||||
conversion_rate=0.000444444,
|
||||
)
|
||||
with (
|
||||
patch("erpnext.controllers.accounts_controller.frappe") as mock_frappe,
|
||||
patch(
|
||||
"erpnext.controllers.accounts_controller.get_exchange_rate",
|
||||
return_value=0.000444444,
|
||||
) as exchange_rate,
|
||||
):
|
||||
mock_frappe.db.get_value.return_value = currency
|
||||
mock_frappe.db.get_single_value.return_value = False
|
||||
AccountsController.set_price_list_currency(doc, direction)
|
||||
self.assertEqual(doc.price_list_currency, currency)
|
||||
self.assertEqual(doc.plc_conversion_rate, expected_rate)
|
||||
self.assertEqual(doc.conversion_rate, 0.000444444)
|
||||
if fetch_rate:
|
||||
exchange_rate.assert_called_once_with(
|
||||
currency, "USD", "2026-09-18", f"for_{direction.lower()}"
|
||||
)
|
||||
else:
|
||||
exchange_rate.assert_not_called()
|
||||
93
erpnext/controllers/tests/test_price_list_validation.py
Normal file
93
erpnext/controllers/tests/test_price_list_validation.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPriceListValidation(ERPNextTestSuite):
|
||||
def create_price_list(self, selling=0, buying=0, enabled=1):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": selling,
|
||||
"buying": buying,
|
||||
"enabled": enabled,
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
def test_selling_transaction_should_reject_a_buying_price_list(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = self.create_price_list(buying=1)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "selling transaction"):
|
||||
invoice.save()
|
||||
|
||||
def test_buying_transaction_should_reject_a_selling_price_list(self):
|
||||
invoice = make_purchase_invoice(do_not_save=1)
|
||||
invoice.buying_price_list = self.create_price_list(selling=1)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "buying transaction"):
|
||||
invoice.save()
|
||||
|
||||
def test_a_price_list_for_both_sides_should_be_accepted(self):
|
||||
price_list = self.create_price_list(selling=1, buying=1)
|
||||
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = price_list
|
||||
invoice.save()
|
||||
|
||||
self.assertEqual(invoice.selling_price_list, price_list)
|
||||
|
||||
def test_a_missing_price_list_should_report_rather_than_crash(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = frappe.generate_hash(length=10)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
invoice.validate_price_list()
|
||||
|
||||
def test_internal_transfer_should_keep_the_outward_price_list(self):
|
||||
"""The inward document of an internal transfer takes the price list of the outward one, which
|
||||
is flagged for the opposite side."""
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
company = "_Test Company with perpetual inventory"
|
||||
selling_only = self.create_price_list(selling=1)
|
||||
|
||||
delivery_note = create_delivery_note(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
cost_center="Main - TCP1",
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
warehouse="Stores - TCP1",
|
||||
target_warehouse=create_warehouse("_Test Transit For Price List", company=company),
|
||||
do_not_submit=1,
|
||||
)
|
||||
delivery_note.selling_price_list = selling_only
|
||||
delivery_note.save()
|
||||
delivery_note.submit()
|
||||
|
||||
receipt = make_inter_company_purchase_receipt(delivery_note.name)
|
||||
receipt.items[0].warehouse = "Stores - TCP1"
|
||||
receipt.save()
|
||||
|
||||
self.assertEqual(receipt.buying_price_list, selling_only)
|
||||
|
||||
def test_disabled_price_list_should_still_report_as_disabled(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = self.create_price_list(selling=1, enabled=0)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
invoice.save()
|
||||
@@ -285,7 +285,7 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
|
||||
}
|
||||
|
||||
if (this.frm.is_new() && this.frm.doc.opportunity_type === undefined) {
|
||||
this.frm.doc.opportunity_type = __("Sales");
|
||||
this.frm.doc.opportunity_type = "Sales";
|
||||
}
|
||||
this.setup_queries();
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
|
||||
def set_opportunity_type(self):
|
||||
if self.is_new() and not self.opportunity_type:
|
||||
self.opportunity_type = _("Sales")
|
||||
self.opportunity_type = "Sales"
|
||||
|
||||
def set_exchange_rate(self):
|
||||
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
|
||||
@@ -252,7 +252,6 @@ class SalesOrder(SellingController):
|
||||
self.validate_warehouse()
|
||||
self.validate_drop_ship()
|
||||
SalesOrderStockReservation(self).validate_reserved_stock()
|
||||
self.validate_serial_no_based_delivery()
|
||||
validate_against_blanket_order(self)
|
||||
validate_inter_company_party(
|
||||
self.doctype, self.customer, self.company, self.inter_company_order_reference
|
||||
@@ -694,41 +693,6 @@ class SalesOrder(SellingController):
|
||||
),
|
||||
)
|
||||
|
||||
def validate_serial_no_based_delivery(self):
|
||||
reserved_items = []
|
||||
normal_items = []
|
||||
for item in self.items:
|
||||
if item.ensure_delivery_based_on_produced_serial_no:
|
||||
if item.item_code in normal_items:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No."
|
||||
).format(item.item_code)
|
||||
)
|
||||
if item.item_code not in reserved_items:
|
||||
if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Item {0} has no Serial No. Only serialized items can have delivery based on Serial No"
|
||||
).format(item.item_code)
|
||||
)
|
||||
if not frappe.db.exists("BOM", {"item": item.item_code, "is_active": 1}):
|
||||
frappe.throw(
|
||||
_(
|
||||
"No active BOM found for item {0}. Delivery by Serial No cannot be ensured"
|
||||
).format(item.item_code)
|
||||
)
|
||||
reserved_items.append(item.item_code)
|
||||
else:
|
||||
normal_items.append(item.item_code)
|
||||
|
||||
if not item.ensure_delivery_based_on_produced_serial_no and item.item_code in reserved_items:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No."
|
||||
).format(item.item_code)
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def has_unreserved_stock(self, table_name: str = "items") -> dict:
|
||||
"""Returns unreserved qty per item if there is any unreserved item in the Sales Order."""
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"customer_item_code",
|
||||
"ensure_delivery_based_on_produced_serial_no",
|
||||
"is_stock_item",
|
||||
"reserve_stock",
|
||||
"col_break1",
|
||||
@@ -165,12 +164,6 @@
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "ensure_delivery_based_on_produced_serial_no",
|
||||
"fieldtype": "Check",
|
||||
"label": "Ensure Delivery Based on Produced Serial No"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -1077,7 +1070,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-27 11:55:37.000000",
|
||||
"modified": "2026-09-21 10:12:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
|
||||
@@ -42,7 +42,6 @@ class SalesOrderItem(Document):
|
||||
discount_amount: DF.Currency
|
||||
discount_percentage: DF.Percent
|
||||
distributed_discount_amount: DF.Currency
|
||||
ensure_delivery_based_on_produced_serial_no: DF.Check
|
||||
fg_item: DF.Link | None
|
||||
fg_item_qty: DF.Float
|
||||
grant_commission: DF.Check
|
||||
|
||||
@@ -89,6 +89,30 @@ frappe.query_reports["Sales Order Analysis"] = {
|
||||
label: __("Group by Sales Order"),
|
||||
fieldtype: "Check",
|
||||
default: 0,
|
||||
on_change: (report) => {
|
||||
if (report.get_filter_value("group_by_so") && report.get_filter_value("group_by_item")) {
|
||||
report.set_filter_value("group_by_item", 0);
|
||||
return;
|
||||
}
|
||||
if (!report._no_refresh) {
|
||||
report.refresh(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "group_by_item",
|
||||
label: __("Group by Item"),
|
||||
fieldtype: "Check",
|
||||
default: 0,
|
||||
on_change: (report) => {
|
||||
if (report.get_filter_value("group_by_so") && report.get_filter_value("group_by_item")) {
|
||||
report.set_filter_value("group_by_so", 0);
|
||||
return;
|
||||
}
|
||||
if (!report._no_refresh) {
|
||||
report.refresh(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -8,18 +8,23 @@ import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.query_builder import Case, CustomFunction
|
||||
from frappe.query_builder.functions import Coalesce, DateDiff, Max, Sum
|
||||
from frappe.utils import date_diff, flt, getdate, nowdate
|
||||
from frappe.utils import date_diff, flt, nowdate
|
||||
|
||||
import erpnext
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
return [], [], None, []
|
||||
|
||||
filters = frappe._dict(filters)
|
||||
filters.company = filters.get("company") or erpnext.get_default_company()
|
||||
|
||||
validate_filters(filters)
|
||||
|
||||
columns = get_columns(filters)
|
||||
data = get_data(filters)
|
||||
so_elapsed_time = get_so_elapsed_time(data)
|
||||
so_elapsed_time = {} if filters.get("group_by_item") else get_so_elapsed_time(data)
|
||||
|
||||
if not data:
|
||||
return [], [], None, []
|
||||
@@ -30,6 +35,9 @@ def execute(filters=None):
|
||||
|
||||
|
||||
def validate_filters(filters):
|
||||
if not filters.get("company"):
|
||||
frappe.throw(_("{0} is mandatory").format(_("Company")))
|
||||
|
||||
from_date, to_date = filters.get("from_date"), filters.get("to_date")
|
||||
|
||||
if not from_date and to_date:
|
||||
@@ -37,6 +45,9 @@ def validate_filters(filters):
|
||||
elif date_diff(to_date, from_date) < 0:
|
||||
frappe.throw(_("To Date cannot be before From Date."))
|
||||
|
||||
if filters.get("group_by_so") and filters.get("group_by_item"):
|
||||
frappe.throw(_("Group the report by Sales Order or by Item, not both."))
|
||||
|
||||
|
||||
def get_data(filters):
|
||||
so = qb.DocType("Sales Order")
|
||||
@@ -65,6 +76,7 @@ def get_data(filters):
|
||||
so.status,
|
||||
so.customer,
|
||||
soi.item_code,
|
||||
soi.uom,
|
||||
delay.as_("delay_days"),
|
||||
Case().when(so.status.isin(["Completed", "To Bill"]), 0).else_(delay).as_("delay"),
|
||||
soi.qty,
|
||||
@@ -81,6 +93,7 @@ def get_data(filters):
|
||||
soi.description.as_("description"),
|
||||
)
|
||||
.where((so.status.notin(["Stopped", "On Hold"])) & (so.docstatus == 1))
|
||||
.where(so.company == filters.get("company"))
|
||||
.groupby(soi.name, so.name)
|
||||
.orderby(so.transaction_date)
|
||||
.orderby(soi.item_code)
|
||||
@@ -88,8 +101,6 @@ def get_data(filters):
|
||||
|
||||
if filters.get("from_date") and filters.get("to_date"):
|
||||
query = query.where(so.transaction_date[filters.get("from_date") : filters.get("to_date")])
|
||||
if filters.get("company"):
|
||||
query = query.where(so.company == filters.get("company"))
|
||||
if filters.get("sales_order"):
|
||||
query = query.where(so.name.isin(filters.get("sales_order")))
|
||||
if filters.get("status"):
|
||||
@@ -151,71 +162,86 @@ def get_so_elapsed_time(data):
|
||||
return so_elapsed_time
|
||||
|
||||
|
||||
AGGREGATED_FIELDS = (
|
||||
"qty",
|
||||
"delivered_qty",
|
||||
"pending_qty",
|
||||
"billed_qty",
|
||||
"qty_to_bill",
|
||||
"amount",
|
||||
"delivered_qty_amount",
|
||||
"billed_amount",
|
||||
"pending_amount",
|
||||
)
|
||||
|
||||
|
||||
def prepare_data(data, so_elapsed_time, filters):
|
||||
completed, pending = 0, 0
|
||||
|
||||
if filters.get("group_by_so"):
|
||||
sales_order_map = {}
|
||||
|
||||
for row in data:
|
||||
# sum data for chart
|
||||
completed += row["billed_amount"]
|
||||
pending += row["pending_amount"]
|
||||
|
||||
# prepare data for report view
|
||||
row["qty_to_bill"] = flt(row["qty"]) - flt(row["billed_qty"])
|
||||
|
||||
row["delay"] = 0 if row["delay"] and row["delay"] < 0 else row["delay"]
|
||||
|
||||
row["time_taken_to_deliver"] = (
|
||||
so_elapsed_time.get((row.sales_order, row.item_code))
|
||||
if row["status"] in ("To Bill", "Completed")
|
||||
else 0
|
||||
)
|
||||
|
||||
if filters.get("group_by_so"):
|
||||
so_name = row["sales_order"]
|
||||
|
||||
if so_name not in sales_order_map:
|
||||
# create an entry
|
||||
row_copy = copy.deepcopy(row)
|
||||
sales_order_map[so_name] = row_copy
|
||||
else:
|
||||
# update existing entry
|
||||
so_row = sales_order_map[so_name]
|
||||
so_row["required_date"] = max(getdate(so_row["delivery_date"]), getdate(row["delivery_date"]))
|
||||
so_row["delay"] = (
|
||||
min(so_row["delay"], row["delay"])
|
||||
if row["delay"] and so_row["delay"]
|
||||
else so_row["delay"]
|
||||
)
|
||||
|
||||
# sum numeric columns
|
||||
fields = [
|
||||
"qty",
|
||||
"delivered_qty",
|
||||
"pending_qty",
|
||||
"billed_qty",
|
||||
"qty_to_bill",
|
||||
"amount",
|
||||
"delivered_qty_amount",
|
||||
"billed_amount",
|
||||
"pending_amount",
|
||||
]
|
||||
for field in fields:
|
||||
so_row[field] = flt(row[field]) + flt(so_row[field])
|
||||
|
||||
chart_data = prepare_chart_data(pending, completed)
|
||||
|
||||
if filters.get("group_by_so"):
|
||||
data = []
|
||||
for so in sales_order_map:
|
||||
data.append(sales_order_map[so])
|
||||
return data, chart_data
|
||||
data = group_by_sales_order(data)
|
||||
elif filters.get("group_by_item"):
|
||||
data = group_by_item(data)
|
||||
|
||||
return data, chart_data
|
||||
|
||||
|
||||
def group_by_sales_order(data):
|
||||
sales_order_map = {}
|
||||
|
||||
for row in data:
|
||||
group = sales_order_map.get(row["sales_order"])
|
||||
if not group:
|
||||
sales_order_map[row["sales_order"]] = copy.deepcopy(row)
|
||||
continue
|
||||
|
||||
group["delay"] = (
|
||||
min(group["delay"], row["delay"]) if row["delay"] and group["delay"] else group["delay"]
|
||||
)
|
||||
add_aggregated_fields(group, row)
|
||||
|
||||
return list(sales_order_map.values())
|
||||
|
||||
|
||||
def group_by_item(data):
|
||||
"""Group on company and UOM as well as the item.
|
||||
|
||||
Quantities are in the line UOM and amounts are in the company currency, so neither sums
|
||||
across a second UOM of the same item or a second company.
|
||||
"""
|
||||
item_map = {}
|
||||
|
||||
for row in data:
|
||||
key = (row["company"], row["item_code"], row["uom"])
|
||||
group = item_map.get(key)
|
||||
if not group:
|
||||
item_map[key] = copy.deepcopy(row)
|
||||
continue
|
||||
|
||||
add_aggregated_fields(group, row)
|
||||
|
||||
return sorted(item_map.values(), key=lambda row: (row["company"], row["item_code"], row["uom"]))
|
||||
|
||||
|
||||
def add_aggregated_fields(group, row):
|
||||
for field in AGGREGATED_FIELDS:
|
||||
group[field] = flt(group[field]) + flt(row[field])
|
||||
|
||||
|
||||
def prepare_chart_data(pending, completed):
|
||||
labels = [_("Amount to Bill"), _("Billed Amount")]
|
||||
|
||||
@@ -227,7 +253,34 @@ def prepare_chart_data(pending, completed):
|
||||
|
||||
|
||||
def get_columns(filters):
|
||||
columns = [
|
||||
if filters.get("group_by_item"):
|
||||
return get_grouped_by_item_columns()
|
||||
|
||||
columns = get_sales_order_columns()
|
||||
|
||||
if not filters.get("group_by_so"):
|
||||
columns += get_item_columns()
|
||||
|
||||
columns += get_quantity_columns() + get_amount_columns() + get_delivery_columns()
|
||||
|
||||
if not filters.get("group_by_so"):
|
||||
columns.append(get_warehouse_column())
|
||||
|
||||
columns.append(get_company_column())
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def get_grouped_by_item_columns():
|
||||
columns = [get_item_code_column(), get_uom_column()]
|
||||
columns += get_quantity_columns() + get_amount_columns()
|
||||
columns.append(get_company_column())
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def get_sales_order_columns():
|
||||
return [
|
||||
{"label": _("Date"), "fieldname": "date", "fieldtype": "Date", "width": 90},
|
||||
{
|
||||
"label": _("Sales Order"),
|
||||
@@ -246,117 +299,139 @@ def get_columns(filters):
|
||||
},
|
||||
]
|
||||
|
||||
if not filters.get("group_by_so"):
|
||||
columns.append(
|
||||
{
|
||||
"label": _("Item Code"),
|
||||
"fieldname": "item_code",
|
||||
"fieldtype": "Link",
|
||||
"options": "Item",
|
||||
"width": 100,
|
||||
}
|
||||
)
|
||||
columns.append(
|
||||
{"label": _("Description"), "fieldname": "description", "fieldtype": "Small Text", "width": 100}
|
||||
)
|
||||
|
||||
columns.extend(
|
||||
[
|
||||
{
|
||||
"label": _("Qty"),
|
||||
"fieldname": "qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Delivered Qty"),
|
||||
"fieldname": "delivered_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Qty to Deliver"),
|
||||
"fieldname": "pending_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Billed Qty"),
|
||||
"fieldname": "billed_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 80,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Qty to Bill"),
|
||||
"fieldname": "qty_to_bill",
|
||||
"fieldtype": "Float",
|
||||
"width": 80,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Amount"),
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 110,
|
||||
"options": "Company:company:default_currency",
|
||||
"convertible": "rate",
|
||||
},
|
||||
{
|
||||
"label": _("Billed Amount"),
|
||||
"fieldname": "billed_amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 110,
|
||||
"options": "Company:company:default_currency",
|
||||
"convertible": "rate",
|
||||
},
|
||||
{
|
||||
"label": _("Pending Amount"),
|
||||
"fieldname": "pending_amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 130,
|
||||
"options": "Company:company:default_currency",
|
||||
"convertible": "rate",
|
||||
},
|
||||
{
|
||||
"label": _("Amount Delivered"),
|
||||
"fieldname": "delivered_qty_amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 100,
|
||||
"options": "Company:company:default_currency",
|
||||
"convertible": "rate",
|
||||
},
|
||||
{"label": _("Delivery Date"), "fieldname": "delivery_date", "fieldtype": "Date", "width": 120},
|
||||
{"label": _("Delay (in Days)"), "fieldname": "delay", "fieldtype": "Data", "width": 100},
|
||||
{
|
||||
"label": _("Time Taken to Deliver"),
|
||||
"fieldname": "time_taken_to_deliver",
|
||||
"fieldtype": "Duration",
|
||||
"width": 100,
|
||||
},
|
||||
]
|
||||
)
|
||||
if not filters.get("group_by_so"):
|
||||
columns.append(
|
||||
{
|
||||
"label": _("Warehouse"),
|
||||
"fieldname": "warehouse",
|
||||
"fieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
"width": 100,
|
||||
}
|
||||
)
|
||||
columns.append(
|
||||
def get_item_columns():
|
||||
return [
|
||||
get_item_code_column(),
|
||||
{"label": _("Description"), "fieldname": "description", "fieldtype": "Small Text", "width": 100},
|
||||
]
|
||||
|
||||
|
||||
def get_item_code_column():
|
||||
return {
|
||||
"label": _("Item Code"),
|
||||
"fieldname": "item_code",
|
||||
"fieldtype": "Link",
|
||||
"options": "Item",
|
||||
"width": 100,
|
||||
}
|
||||
|
||||
|
||||
def get_uom_column():
|
||||
return {
|
||||
"label": _("UOM"),
|
||||
"fieldname": "uom",
|
||||
"fieldtype": "Link",
|
||||
"options": "UOM",
|
||||
"width": 100,
|
||||
}
|
||||
|
||||
|
||||
def get_quantity_columns():
|
||||
return [
|
||||
{
|
||||
"label": _("Company"),
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"options": "Company",
|
||||
"width": 100,
|
||||
}
|
||||
)
|
||||
"label": _("Qty"),
|
||||
"fieldname": "qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Delivered Qty"),
|
||||
"fieldname": "delivered_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Qty to Deliver"),
|
||||
"fieldname": "pending_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Billed Qty"),
|
||||
"fieldname": "billed_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 80,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Qty to Bill"),
|
||||
"fieldname": "qty_to_bill",
|
||||
"fieldtype": "Float",
|
||||
"width": 80,
|
||||
"convertible": "qty",
|
||||
},
|
||||
]
|
||||
|
||||
return columns
|
||||
|
||||
def get_amount_columns():
|
||||
return [
|
||||
{
|
||||
"label": _("Amount"),
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 110,
|
||||
"options": "Company:company:default_currency",
|
||||
"convertible": "rate",
|
||||
},
|
||||
{
|
||||
"label": _("Billed Amount"),
|
||||
"fieldname": "billed_amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 110,
|
||||
"options": "Company:company:default_currency",
|
||||
"convertible": "rate",
|
||||
},
|
||||
{
|
||||
"label": _("Pending Amount"),
|
||||
"fieldname": "pending_amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 130,
|
||||
"options": "Company:company:default_currency",
|
||||
"convertible": "rate",
|
||||
},
|
||||
{
|
||||
"label": _("Amount Delivered"),
|
||||
"fieldname": "delivered_qty_amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 100,
|
||||
"options": "Company:company:default_currency",
|
||||
"convertible": "rate",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_delivery_columns():
|
||||
return [
|
||||
{"label": _("Delivery Date"), "fieldname": "delivery_date", "fieldtype": "Date", "width": 120},
|
||||
{"label": _("Delay (in Days)"), "fieldname": "delay", "fieldtype": "Data", "width": 100},
|
||||
{
|
||||
"label": _("Time Taken to Deliver"),
|
||||
"fieldname": "time_taken_to_deliver",
|
||||
"fieldtype": "Duration",
|
||||
"width": 100,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_warehouse_column():
|
||||
return {
|
||||
"label": _("Warehouse"),
|
||||
"fieldname": "warehouse",
|
||||
"fieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
"width": 100,
|
||||
}
|
||||
|
||||
|
||||
def get_company_column():
|
||||
return {
|
||||
"label": _("Company"),
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"options": "Company",
|
||||
"width": 100,
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days
|
||||
|
||||
from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_order_analysis.sales_order_analysis import execute
|
||||
from erpnext.selling.report.sales_order_analysis.sales_order_analysis import (
|
||||
AGGREGATED_FIELDS,
|
||||
execute,
|
||||
group_by_item,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSalesOrderAnalysis(ERPNextTestSuite):
|
||||
def create_sales_order(self, transaction_date, do_not_save=False, do_not_submit=False):
|
||||
def create_sales_order(self, transaction_date, do_not_save=False, do_not_submit=False, qty=10, uom=None):
|
||||
item = create_item(item_code="_Test Excavator", is_stock_item=0)
|
||||
so = make_sales_order(
|
||||
transaction_date=transaction_date,
|
||||
item=item.item_code,
|
||||
qty=10,
|
||||
qty=qty,
|
||||
uom=uom,
|
||||
rate=100000,
|
||||
do_not_save=True,
|
||||
)
|
||||
@@ -28,6 +35,17 @@ class TestSalesOrderAnalysis(ERPNextTestSuite):
|
||||
so.submit()
|
||||
return item, so
|
||||
|
||||
def make_item_row(self, company, qty):
|
||||
row = frappe._dict(dict.fromkeys(AGGREGATED_FIELDS, 0))
|
||||
row.update({"company": company, "item_code": "_Test Excavator", "uom": "Nos", "qty": qty})
|
||||
return row
|
||||
|
||||
def add_uom(self, item_code, uom, conversion_factor):
|
||||
item = frappe.get_doc("Item", item_code)
|
||||
if not any(row.uom == uom for row in item.uoms):
|
||||
item.append("uoms", {"uom": uom, "conversion_factor": conversion_factor})
|
||||
item.save()
|
||||
|
||||
def create_sales_invoice(self, so, do_not_save=False, do_not_submit=False):
|
||||
sinv = make_sales_invoice(so.name)
|
||||
sinv.posting_date = so.transaction_date
|
||||
@@ -255,3 +273,104 @@ class TestSalesOrderAnalysis(ERPNextTestSuite):
|
||||
for key, val in expected_value.items():
|
||||
with self.subTest(key=key, val=val):
|
||||
self.assertEqual(data[0][key], val)
|
||||
|
||||
def test_08_group_by_item_across_sales_orders(self):
|
||||
transaction_date = "2021-06-01"
|
||||
item, so1 = self.create_sales_order(transaction_date)
|
||||
self.create_sales_order(transaction_date, qty=4)
|
||||
|
||||
dn = self.create_delivery_note(so1, do_not_save=True)
|
||||
dn.items[0].qty = 3
|
||||
dn.save().submit()
|
||||
|
||||
columns, data, message, chart = execute(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": "2021-06-01",
|
||||
"to_date": "2021-06-30",
|
||||
"group_by_item": 1,
|
||||
}
|
||||
)
|
||||
expected_value = {
|
||||
"item_code": item.item_code,
|
||||
"uom": "Nos",
|
||||
"qty": 14,
|
||||
"delivered_qty": 3,
|
||||
"pending_qty": 11,
|
||||
}
|
||||
self.assertEqual(len(data), 1)
|
||||
for key, val in expected_value.items():
|
||||
with self.subTest(key=key, val=val):
|
||||
self.assertEqual(data[0][key], val)
|
||||
|
||||
fieldnames = [column["fieldname"] for column in columns]
|
||||
self.assertIn("uom", fieldnames)
|
||||
self.assertNotIn("sales_order", fieldnames)
|
||||
|
||||
def test_09_group_by_item_keeps_each_uom_apart(self):
|
||||
transaction_date = "2021-06-01"
|
||||
item, so = self.create_sales_order(transaction_date)
|
||||
self.add_uom(item.item_code, "Box", 10)
|
||||
self.create_sales_order(transaction_date, qty=2, uom="Box")
|
||||
|
||||
columns, data, message, chart = execute(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": "2021-06-01",
|
||||
"to_date": "2021-06-30",
|
||||
"group_by_item": 1,
|
||||
}
|
||||
)
|
||||
self.assertEqual(len(data), 2)
|
||||
self.assertEqual([(row["uom"], row["qty"]) for row in data], [("Box", 2), ("Nos", 10)])
|
||||
|
||||
def test_10_group_by_filters_cannot_be_combined(self):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
execute,
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": "2021-06-01",
|
||||
"to_date": "2021-06-30",
|
||||
"group_by_so": 1,
|
||||
"group_by_item": 1,
|
||||
},
|
||||
)
|
||||
|
||||
def test_11_group_by_item_keeps_each_company_apart(self):
|
||||
rows = [
|
||||
self.make_item_row("_Test Company", 10),
|
||||
self.make_item_row("_Test Company 1", 4),
|
||||
self.make_item_row("_Test Company", 6),
|
||||
]
|
||||
|
||||
grouped = group_by_item(rows)
|
||||
|
||||
self.assertEqual(
|
||||
[(row["company"], row["qty"]) for row in grouped],
|
||||
[("_Test Company", 16), ("_Test Company 1", 4)],
|
||||
)
|
||||
|
||||
def test_12_company_falls_back_to_the_default(self):
|
||||
transaction_date = "2021-06-01"
|
||||
item, so = self.create_sales_order(transaction_date)
|
||||
filters = {"from_date": "2021-06-01", "to_date": "2021-06-30"}
|
||||
|
||||
with patch("erpnext.get_default_company", return_value="_Test Company"):
|
||||
columns, data, message, chart = execute(filters)
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["sales_order"], so.name)
|
||||
|
||||
with patch("erpnext.get_default_company", return_value="_Test Company 1"):
|
||||
columns, data, message, chart = execute(filters)
|
||||
|
||||
self.assertNotIn(so.name, [row["sales_order"] for row in data])
|
||||
|
||||
def test_13_company_is_mandatory_without_a_default(self):
|
||||
with patch("erpnext.get_default_company", return_value=None):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
execute,
|
||||
{"from_date": "2021-06-01", "to_date": "2021-06-30"},
|
||||
)
|
||||
|
||||
@@ -97,6 +97,27 @@ class TestBin(ERPNextTestSuite):
|
||||
self.assertEqual(bin.valuation_rate, 0)
|
||||
self.assertEqual(bin.stock_value, 0)
|
||||
|
||||
def test_cancelling_transfer_restores_bin_stock_value(self):
|
||||
"""Cancelling a transfer must put back the stock value of both bins, not just the quantity."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item_code = make_item().name
|
||||
source = "_Test Warehouse - _TC"
|
||||
target = "_Test Warehouse 1 - _TC"
|
||||
make_stock_entry(item_code=item_code, target=source, qty=10, rate=100, posting_time="01:00:00")
|
||||
make_stock_entry(item_code=item_code, target=target, qty=10, rate=200, posting_time="02:00:00")
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item_code, source=source, target=target, qty=5, posting_time="03:00:00"
|
||||
)
|
||||
se.cancel()
|
||||
|
||||
for warehouse, valuation_rate, stock_value in ((source, 100, 1000), (target, 200, 2000)):
|
||||
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
self.assertEqual(bin.actual_qty, 10)
|
||||
self.assertEqual(bin.valuation_rate, valuation_rate)
|
||||
self.assertEqual(bin.stock_value, stock_value)
|
||||
|
||||
def test_deleting_last_voucher_resets_bin(self):
|
||||
"""Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
@@ -1804,6 +1804,229 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
self.assertEqual(dn.items[0].rate, rate)
|
||||
self.assertEqual(dn.items[0].net_rate, rate)
|
||||
|
||||
def test_internal_transfer_carries_the_batch_into_transit(self):
|
||||
"""Material sent to an in-transit warehouse keeps the batch it left the source warehouse with."""
|
||||
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
|
||||
|
||||
company = "_Test Company"
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
transit_warehouse = "Stores - _TC"
|
||||
item = make_item(
|
||||
properties={
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "_T-TRANSIT-BATCH-.####",
|
||||
}
|
||||
).name
|
||||
customer = create_internal_customer(represents_company=company)
|
||||
|
||||
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=item)
|
||||
|
||||
dn = create_delivery_note(
|
||||
item_code=item,
|
||||
company=company,
|
||||
customer=customer,
|
||||
qty=5,
|
||||
rate=100,
|
||||
warehouse=warehouse,
|
||||
target_warehouse=transit_warehouse,
|
||||
)
|
||||
|
||||
packages = {
|
||||
d.warehouse: d.name
|
||||
for d in frappe.get_all(
|
||||
"Serial and Batch Bundle", filters={"voucher_no": dn.name}, fields=["name", "warehouse"]
|
||||
)
|
||||
}
|
||||
sent_batch = frappe.db.get_value(
|
||||
"Serial and Batch Entry", {"parent": packages[warehouse]}, "batch_no"
|
||||
)
|
||||
received_batch = frappe.db.get_value(
|
||||
"Serial and Batch Entry", {"parent": packages[transit_warehouse]}, "batch_no"
|
||||
)
|
||||
|
||||
self.assertEqual(received_batch, sent_batch)
|
||||
self.assertEqual(frappe.db.count("Batch", {"item": item}), 1)
|
||||
|
||||
def test_internal_transfer_carries_the_batch_of_a_bundle_component(self):
|
||||
"""A batched component of a product bundle keeps its batch on the way to transit."""
|
||||
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
|
||||
company = "_Test Company"
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
transit_warehouse = "Stores - _TC"
|
||||
component = make_item(
|
||||
properties={
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "_T-BUNDLE-BATCH-.####",
|
||||
}
|
||||
).name
|
||||
bundle_item = make_item(properties={"is_stock_item": 0}).name
|
||||
make_product_bundle(bundle_item, [component], qty=1)
|
||||
customer = create_internal_customer(represents_company=company)
|
||||
|
||||
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=component)
|
||||
|
||||
dn = create_delivery_note(
|
||||
item_code=bundle_item,
|
||||
company=company,
|
||||
customer=customer,
|
||||
qty=5,
|
||||
rate=100,
|
||||
warehouse=warehouse,
|
||||
target_warehouse=transit_warehouse,
|
||||
)
|
||||
|
||||
packages = {
|
||||
d.warehouse: d.name
|
||||
for d in frappe.get_all(
|
||||
"Serial and Batch Bundle", filters={"voucher_no": dn.name}, fields=["name", "warehouse"]
|
||||
)
|
||||
}
|
||||
sent_batch = frappe.db.get_value(
|
||||
"Serial and Batch Entry", {"parent": packages[warehouse]}, "batch_no"
|
||||
)
|
||||
received_batch = frappe.db.get_value(
|
||||
"Serial and Batch Entry", {"parent": packages[transit_warehouse]}, "batch_no"
|
||||
)
|
||||
|
||||
self.assertEqual(received_batch, sent_batch)
|
||||
self.assertEqual(frappe.db.count("Batch", {"item": component}), 1)
|
||||
|
||||
def test_internal_transfer_of_a_bundle_with_a_repeated_component(self):
|
||||
"""A component listed twice on a bundle keeps its batch on both packed rows."""
|
||||
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
|
||||
|
||||
company = "_Test Company"
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
transit_warehouse = "Stores - _TC"
|
||||
component = make_item(
|
||||
properties={
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "_T-REPEATED-BATCH-.####",
|
||||
}
|
||||
).name
|
||||
bundle_item = make_item(properties={"is_stock_item": 0}).name
|
||||
|
||||
product_bundle = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": bundle_item})
|
||||
product_bundle.append("items", {"item_code": component, "qty": 1})
|
||||
product_bundle.append("items", {"item_code": component, "qty": 2})
|
||||
product_bundle.insert()
|
||||
product_bundle.submit()
|
||||
|
||||
make_stock_entry(target=warehouse, qty=20, basic_rate=100, item_code=component)
|
||||
customer = create_internal_customer(represents_company=company)
|
||||
|
||||
dn = create_delivery_note(
|
||||
item_code=bundle_item,
|
||||
company=company,
|
||||
customer=customer,
|
||||
qty=5,
|
||||
rate=100,
|
||||
warehouse=warehouse,
|
||||
target_warehouse=transit_warehouse,
|
||||
)
|
||||
|
||||
received = frappe.get_all(
|
||||
"Serial and Batch Bundle",
|
||||
filters={"voucher_no": dn.name, "warehouse": transit_warehouse},
|
||||
pluck="total_qty",
|
||||
)
|
||||
self.assertEqual(sorted(received), [5, 10])
|
||||
self.assertEqual(frappe.db.count("Batch", {"item": component}), 1)
|
||||
|
||||
def test_internal_transfer_return_carries_the_batch_back(self):
|
||||
"""Material coming back from an in-transit warehouse returns under the batch it left with."""
|
||||
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
|
||||
|
||||
company = "_Test Company"
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
transit_warehouse = "Stores - _TC"
|
||||
item = make_item(
|
||||
properties={
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "_T-RETURNED-BATCH-.####",
|
||||
}
|
||||
).name
|
||||
customer = create_internal_customer(represents_company=company)
|
||||
|
||||
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=item)
|
||||
|
||||
dn = create_delivery_note(
|
||||
item_code=item,
|
||||
company=company,
|
||||
customer=customer,
|
||||
qty=5,
|
||||
rate=100,
|
||||
warehouse=warehouse,
|
||||
target_warehouse=transit_warehouse,
|
||||
)
|
||||
|
||||
returned = create_delivery_note(
|
||||
item_code=item,
|
||||
company=company,
|
||||
customer=customer,
|
||||
qty=-5,
|
||||
rate=100,
|
||||
warehouse=warehouse,
|
||||
target_warehouse=transit_warehouse,
|
||||
is_return=1,
|
||||
return_against=dn.name,
|
||||
)
|
||||
|
||||
received_package = frappe.db.get_value(
|
||||
"Serial and Batch Bundle", {"voucher_no": returned.name, "warehouse": warehouse}
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial and Batch Entry", {"parent": received_package}, "batch_no"),
|
||||
frappe.db.get_value(
|
||||
"Serial and Batch Entry",
|
||||
{
|
||||
"parent": frappe.db.get_value(
|
||||
"Serial and Batch Bundle", {"voucher_no": dn.name, "warehouse": warehouse}
|
||||
)
|
||||
},
|
||||
"batch_no",
|
||||
),
|
||||
)
|
||||
self.assertEqual(frappe.db.count("Batch", {"item": item}), 1)
|
||||
|
||||
def test_internal_transfer_of_an_item_that_cannot_create_batches(self):
|
||||
"""An item whose batches are made by hand travels through an in-transit warehouse."""
|
||||
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
|
||||
|
||||
company = "_Test Company"
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
transit_warehouse = "Stores - _TC"
|
||||
item = make_item(properties={"has_batch_no": 1, "create_new_batch": 0}).name
|
||||
batch = frappe.get_doc({"doctype": "Batch", "batch_id": f"_T-MANUAL-{item}", "item": item}).insert()
|
||||
customer = create_internal_customer(represents_company=company)
|
||||
|
||||
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=item, batch_no=batch.name)
|
||||
|
||||
with self.change_settings("Stock Settings", auto_create_serial_and_batch_bundle_for_outward=1):
|
||||
dn = create_delivery_note(
|
||||
item_code=item,
|
||||
company=company,
|
||||
customer=customer,
|
||||
qty=5,
|
||||
rate=100,
|
||||
warehouse=warehouse,
|
||||
target_warehouse=transit_warehouse,
|
||||
)
|
||||
|
||||
received_package = frappe.db.get_value(
|
||||
"Serial and Batch Bundle", {"voucher_no": dn.name, "warehouse": transit_warehouse}
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial and Batch Entry", {"parent": received_package}, "batch_no"),
|
||||
batch.name,
|
||||
)
|
||||
|
||||
def test_internal_transfer_precision_gle(self):
|
||||
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
|
||||
|
||||
|
||||
@@ -73,24 +73,42 @@ erpnext.stock.LandedCostVoucher = class LandedCostVoucher extends erpnext.stock.
|
||||
}
|
||||
|
||||
set_applicable_charges_for_item() {
|
||||
var me = this;
|
||||
|
||||
if (this.frm.doc.taxes.length) {
|
||||
var total_item_cost = 0.0;
|
||||
var based_on = this.frm.doc.distribute_charges_based_on.toLowerCase();
|
||||
|
||||
if (based_on != "distribute manually") {
|
||||
$.each(this.frm.doc.items || [], function (i, d) {
|
||||
total_item_cost += flt(d[based_on]);
|
||||
var items = this.frm.doc.items || [];
|
||||
items.forEach((item) => {
|
||||
total_item_cost += flt(item[based_on]);
|
||||
});
|
||||
|
||||
if (items.length) {
|
||||
total_item_cost = flt(total_item_cost, precision(based_on, items[0]));
|
||||
}
|
||||
|
||||
if (!total_item_cost) {
|
||||
items.forEach((item) => {
|
||||
item.applicable_charges = 0;
|
||||
});
|
||||
refresh_field("items");
|
||||
if (items.length) {
|
||||
frappe.show_alert({
|
||||
message: __(
|
||||
"Total {0} of all items is zero, charges cannot be distributed on it.",
|
||||
[this.frm.doc.distribute_charges_based_on]
|
||||
),
|
||||
indicator: "red",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var total_charges = 0.0;
|
||||
$.each(this.frm.doc.items || [], function (i, item) {
|
||||
item.applicable_charges =
|
||||
(flt(item[based_on]) * flt(me.frm.doc.total_taxes_and_charges)) /
|
||||
flt(total_item_cost);
|
||||
items.forEach((item) => {
|
||||
item.applicable_charges = flt(
|
||||
item.applicable_charges,
|
||||
(flt(item[based_on]) * flt(this.frm.doc.total_taxes_and_charges)) /
|
||||
flt(total_item_cost),
|
||||
precision("applicable_charges", item)
|
||||
);
|
||||
total_charges += item.applicable_charges;
|
||||
@@ -98,7 +116,7 @@ erpnext.stock.LandedCostVoucher = class LandedCostVoucher extends erpnext.stock.
|
||||
|
||||
if (total_charges != this.frm.doc.total_taxes_and_charges) {
|
||||
var diff = this.frm.doc.total_taxes_and_charges - flt(total_charges);
|
||||
this.frm.doc.items.slice(-1)[0].applicable_charges += diff;
|
||||
items.slice(-1)[0].applicable_charges += diff;
|
||||
}
|
||||
refresh_field("items");
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class LandedCostVoucher(Document):
|
||||
item.item_code = d.item_code
|
||||
item.description = d.description
|
||||
item.qty = d.qty
|
||||
item.rate = d.get("base_rate") or d.get("rate")
|
||||
item.rate = d.base_rate
|
||||
item.cost_center = d.cost_center or erpnext.get_default_cost_center(self.company)
|
||||
item.amount = d.base_amount
|
||||
item.receipt_document_type = pr.receipt_document_type
|
||||
@@ -306,22 +306,24 @@ class LandedCostVoucher(Document):
|
||||
|
||||
def set_applicable_charges_on_item(self):
|
||||
if self.get("taxes") and self.distribute_charges_based_on != "Distribute Manually":
|
||||
total_item_cost = 0.0
|
||||
items = self.get("items")
|
||||
total_charges = 0.0
|
||||
item_count = 0
|
||||
based_on_field = frappe.scrub(self.distribute_charges_based_on)
|
||||
|
||||
for item in self.get("items"):
|
||||
total_item_cost += item.get(based_on_field)
|
||||
total_item_cost = sum(flt(item.get(based_on_field)) for item in items)
|
||||
if items:
|
||||
total_item_cost = flt(total_item_cost, items[0].precision(based_on_field))
|
||||
|
||||
for item in self.get("items"):
|
||||
if not total_item_cost and not item.get(based_on_field):
|
||||
frappe.throw(
|
||||
_(
|
||||
"It's not possible to distribute charges equally when total amount is zero, please set 'Distribute Charges Based On' as 'Quantity'"
|
||||
)
|
||||
if not total_item_cost:
|
||||
frappe.throw(
|
||||
_("Total {0} of all items is zero. Set 'Distribute Charges Based On' to {1}.").format(
|
||||
self.distribute_charges_based_on,
|
||||
_("Qty") if based_on_field == "amount" else _("Amount"),
|
||||
)
|
||||
)
|
||||
|
||||
for item in self.get("items"):
|
||||
item.applicable_charges = flt(
|
||||
flt(item.get(based_on_field))
|
||||
* (flt(self.total_taxes_and_charges) / flt(total_item_cost)),
|
||||
@@ -566,8 +568,8 @@ def get_pr_items(purchase_receipt):
|
||||
query = query.where(pr_item.is_finished_item == 1)
|
||||
else:
|
||||
query = query.select(
|
||||
pr_item.base_rate,
|
||||
pr_item.base_amount,
|
||||
pr_item.base_net_rate.as_("base_rate"),
|
||||
pr_item.base_net_amount.as_("base_amount"),
|
||||
pr_item.is_fixed_asset,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +28,103 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.load_test_records("Currency Exchange")
|
||||
|
||||
def test_landed_cost_uses_discounted_purchase_values(self):
|
||||
for make_purchase in (make_purchase_receipt, make_purchase_invoice):
|
||||
for apply_discount_on in ("Net Total", "Grand Total"):
|
||||
with self.subTest(purchase=make_purchase.__name__, apply_discount_on=apply_discount_on):
|
||||
lcv = frappe.new_doc("Landed Cost Voucher")
|
||||
lcv.company = "_Test Company"
|
||||
lcv.distribute_charges_based_on = "Amount"
|
||||
for discount in (40, 0, 100):
|
||||
purchase = make_purchase(qty=2, rate=100, update_stock=1, do_not_save=True)
|
||||
purchase.apply_discount_on = apply_discount_on
|
||||
purchase.additional_discount_percentage = discount
|
||||
purchase.items[0].allow_zero_valuation_rate = 1
|
||||
purchase.insert()
|
||||
purchase.submit()
|
||||
lcv.append(
|
||||
"purchase_receipts",
|
||||
{
|
||||
"receipt_document_type": purchase.doctype,
|
||||
"receipt_document": purchase.name,
|
||||
},
|
||||
)
|
||||
|
||||
lcv.get_items_from_purchase_receipts()
|
||||
self.assertEqual([item.amount for item in lcv.items], [120, 200, 0])
|
||||
self.assertEqual([item.rate for item in lcv.items], [60, 100, 0])
|
||||
lcv.append("taxes", {"amount": 80})
|
||||
lcv.total_taxes_and_charges = 80
|
||||
lcv.set_applicable_charges_on_item()
|
||||
self.assertEqual([item.applicable_charges for item in lcv.items], [30, 50, 0])
|
||||
|
||||
def test_landed_cost_rejects_offsetting_purchase_and_return_amounts(self):
|
||||
for make_purchase in (make_purchase_receipt, make_purchase_invoice):
|
||||
with self.subTest(purchase=make_purchase.__name__):
|
||||
purchase = make_purchase(qty=2, rate=100, update_stock=1, do_not_save=True)
|
||||
purchase.apply_discount_on = "Grand Total"
|
||||
purchase.additional_discount_percentage = 50
|
||||
purchase.insert()
|
||||
purchase.submit()
|
||||
original = make_purchase(qty=1, rate=100, update_stock=1)
|
||||
purchase_return = make_purchase(
|
||||
qty=-1, rate=100, update_stock=1, is_return=1, return_against=original.name
|
||||
)
|
||||
lcv = make_landed_cost_voucher(
|
||||
receipt_document_type=purchase.doctype,
|
||||
receipt_document=purchase.name,
|
||||
charges=80,
|
||||
do_not_save=True,
|
||||
)
|
||||
lcv.append(
|
||||
"purchase_receipts",
|
||||
{
|
||||
"receipt_document_type": purchase_return.doctype,
|
||||
"receipt_document": purchase_return.name,
|
||||
},
|
||||
)
|
||||
lcv.get_items_from_purchase_receipts()
|
||||
self.assertEqual([item.amount for item in lcv.items], [100, -100])
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "of all items is zero"):
|
||||
lcv.insert()
|
||||
lcv.distribute_charges_based_on = "Qty"
|
||||
lcv.insert()
|
||||
self.assertEqual([item.applicable_charges for item in lcv.items], [160, -80])
|
||||
|
||||
def test_landed_cost_rejects_fully_discounted_purchase(self):
|
||||
for make_purchase in (make_purchase_receipt, make_purchase_invoice):
|
||||
with self.subTest(purchase=make_purchase.__name__):
|
||||
purchase = make_purchase(qty=2, rate=100, update_stock=1, do_not_save=True)
|
||||
purchase.apply_discount_on = "Net Total"
|
||||
purchase.additional_discount_percentage = 100
|
||||
purchase.items[0].allow_zero_valuation_rate = 1
|
||||
purchase.insert()
|
||||
purchase.submit()
|
||||
lcv = make_landed_cost_voucher(
|
||||
receipt_document_type=purchase.doctype,
|
||||
receipt_document=purchase.name,
|
||||
charges=80,
|
||||
do_not_save=True,
|
||||
)
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "of all items is zero"):
|
||||
lcv.insert()
|
||||
lcv.distribute_charges_based_on = "Qty"
|
||||
lcv.insert()
|
||||
self.assertEqual([item.applicable_charges for item in lcv.items], [80])
|
||||
|
||||
def test_landed_cost_rejects_amounts_that_cancel_to_float_residue(self):
|
||||
lcv = frappe.new_doc("Landed Cost Voucher")
|
||||
lcv.company = "_Test Company"
|
||||
lcv.distribute_charges_based_on = "Amount"
|
||||
for amount in (100.10, 200.20, -300.30):
|
||||
lcv.append("items", {"item_code": "_Test Item", "qty": 1, "amount": amount})
|
||||
lcv.append("taxes", {"amount": 80})
|
||||
lcv.total_taxes_and_charges = 80
|
||||
|
||||
self.assertNotEqual(sum(item.amount for item in lcv.items), 0)
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "of all items is zero"):
|
||||
lcv.set_applicable_charges_on_item()
|
||||
|
||||
def test_get_vendor_invoices_runs(self):
|
||||
# get_vendor_invoice_query filters unclaimed vendor invoices; the threshold moved from a HAVING
|
||||
# (which referenced a SELECT alias with no GROUP BY -- invalid on Postgres) to a WHERE.
|
||||
|
||||
@@ -22,6 +22,46 @@ class BaseMaterialTransferStockEntry(BaseStockEntry):
|
||||
if not row.s_warehouse:
|
||||
frappe.throw(_("Source Warehouse is required for item {0}").format(row.item_code))
|
||||
|
||||
self.validate_transit_warehouses()
|
||||
|
||||
def validate_transit_warehouses(self):
|
||||
if not self.doc.add_to_transit:
|
||||
return
|
||||
|
||||
target_warehouses = {row.t_warehouse for row in self.doc.items if row.t_warehouse}
|
||||
if self.doc.to_warehouse:
|
||||
target_warehouses.add(self.doc.to_warehouse)
|
||||
|
||||
if not target_warehouses:
|
||||
return
|
||||
|
||||
transit_warehouses = set(
|
||||
frappe.get_all(
|
||||
"Warehouse",
|
||||
filters={
|
||||
"name": ("in", list(target_warehouses)),
|
||||
"warehouse_type": "Transit",
|
||||
"company": self.doc.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
if self.doc.to_warehouse and self.doc.to_warehouse not in transit_warehouses:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Default Target Warehouse {0} must be a Transit warehouse when Add to Transit is enabled."
|
||||
).format(frappe.bold(self.doc.to_warehouse))
|
||||
)
|
||||
|
||||
for row in self.doc.items:
|
||||
if row.t_warehouse and row.t_warehouse not in transit_warehouses:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Target Warehouse {1} must be a Transit warehouse when Add to Transit is enabled."
|
||||
).format(row.idx, frappe.bold(row.t_warehouse))
|
||||
)
|
||||
|
||||
def validate_same_source_target_warehouse(self):
|
||||
"""
|
||||
Raises: frappe.ValidationError: If warehouses are same and no inventory dimensions differ
|
||||
|
||||
@@ -903,22 +903,15 @@ frappe.ui.form.on("Stock Entry", {
|
||||
|
||||
add_to_transit: function (frm) {
|
||||
if (frm.doc.purpose == "Material Transfer") {
|
||||
var filters = {
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
};
|
||||
|
||||
if (frm.doc.add_to_transit) {
|
||||
filters["warehouse_type"] = "Transit";
|
||||
frm.set_value("to_warehouse", "");
|
||||
(frm.doc.items || []).forEach((item) => {
|
||||
if (item.t_warehouse) {
|
||||
frappe.model.set_value(item.doctype, item.name, "t_warehouse", "");
|
||||
}
|
||||
});
|
||||
frm.trigger("set_transit_warehouse");
|
||||
}
|
||||
|
||||
frm.fields_dict.to_warehouse.get_query = function () {
|
||||
return {
|
||||
filters: filters,
|
||||
};
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1230,6 +1223,28 @@ frappe.ui.form.on("Landed Cost Taxes and Charges", {
|
||||
});
|
||||
|
||||
erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockController {
|
||||
setup_warehouse_query() {
|
||||
super.setup_warehouse_query();
|
||||
|
||||
const transit_warehouse_query = () => {
|
||||
const filters = {
|
||||
is_group: 0,
|
||||
company: this.frm.doc.company,
|
||||
};
|
||||
|
||||
if (this.frm.doc.purpose === "Material Transfer" && this.frm.doc.add_to_transit) {
|
||||
filters["warehouse_type"] = "Transit";
|
||||
}
|
||||
|
||||
return {
|
||||
filters: filters,
|
||||
};
|
||||
};
|
||||
|
||||
this.frm.set_query("to_warehouse", transit_warehouse_query);
|
||||
this.frm.set_query("t_warehouse", "items", transit_warehouse_query);
|
||||
}
|
||||
|
||||
setup() {
|
||||
var me = this;
|
||||
|
||||
@@ -1419,7 +1434,10 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
|
||||
this.frm.trigger("toggle_display_account_head");
|
||||
|
||||
erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype);
|
||||
this.set_default_account("cost_center", "cost_center");
|
||||
|
||||
if (!this.frm.doc.__onload?.load_after_mapping) {
|
||||
this.set_default_account("cost_center", "cost_center");
|
||||
}
|
||||
|
||||
this.frm.refresh_fields("items");
|
||||
}
|
||||
|
||||
@@ -1750,6 +1750,12 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_in_entry(source_name: str, target_doc: str | dict | Document | None = None):
|
||||
qty_precision = frappe.get_precision("Stock Entry Detail", "transfer_qty")
|
||||
|
||||
def get_remaining_transfer_qty(source_doc):
|
||||
remaining_qty = flt(source_doc.transfer_qty) - flt(source_doc.transferred_qty)
|
||||
return flt(remaining_qty, qty_precision)
|
||||
|
||||
def set_missing_values(source, target):
|
||||
target.stock_entry_type = "Material Transfer"
|
||||
target.set_missing_values()
|
||||
@@ -1769,7 +1775,7 @@ def make_stock_in_entry(source_name: str, target_doc: str | dict | Document | No
|
||||
target_doc.t_warehouse = warehouse
|
||||
|
||||
target_doc.s_warehouse = source_doc.t_warehouse
|
||||
target_doc.qty = source_doc.qty - source_doc.transferred_qty
|
||||
target_doc.qty = get_remaining_transfer_qty(source_doc) / flt(source_doc.conversion_factor)
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Stock Entry",
|
||||
@@ -1789,7 +1795,7 @@ def make_stock_in_entry(source_name: str, target_doc: str | dict | Document | No
|
||||
"batch_no": "batch_no",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: flt(doc.qty) - flt(doc.transferred_qty) > 0.00001,
|
||||
"condition": lambda doc: get_remaining_transfer_qty(doc) > 0,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
|
||||
@@ -272,7 +272,8 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
company = "_Test Company"
|
||||
|
||||
create_warehouse("Test From Warehouse")
|
||||
create_warehouse("Test Transit Warehouse")
|
||||
create_warehouse("Test Transit Warehouse", properties={"warehouse_type": "Transit"})
|
||||
frappe.db.set_value("Warehouse", "Test Transit Warehouse - _TC", "warehouse_type", "Transit")
|
||||
create_warehouse("Test To Warehouse")
|
||||
|
||||
create_item(
|
||||
@@ -323,6 +324,131 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
transit_entry.reload()
|
||||
self.assertEqual(transit_entry.per_transferred, 100)
|
||||
|
||||
def test_end_transit_qty_with_uom_conversion(self):
|
||||
"""transferred_qty is tracked in the stock UOM, so the end transit qty must be converted back."""
|
||||
company = "_Test Company"
|
||||
source_warehouse = "_Test Warehouse - _TC"
|
||||
target_warehouse = "_Test Warehouse 1 - _TC"
|
||||
transit_warehouse = get_in_transit_warehouse(company)
|
||||
|
||||
item_code = make_item(
|
||||
"_Test Transit UOM Conversion Item",
|
||||
{"is_stock_item": 1, "stock_uom": "Nos", "uoms": [{"uom": "Kg", "conversion_factor": 0.5}]},
|
||||
).name
|
||||
|
||||
make_stock_entry(item_code=item_code, target=source_warehouse, qty=100, basic_rate=100)
|
||||
|
||||
transit_entry = make_stock_entry(
|
||||
item_code=item_code,
|
||||
source=source_warehouse,
|
||||
target=transit_warehouse,
|
||||
purpose="Material Transfer",
|
||||
add_to_transit=1,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
do_not_save=True,
|
||||
)
|
||||
transit_entry.items[0].uom = "Kg"
|
||||
transit_entry.items[0].conversion_factor = 0.5
|
||||
transit_entry.save().submit()
|
||||
self.assertEqual(transit_entry.items[0].transfer_qty, 5)
|
||||
|
||||
partial_entry = make_stock_in_entry(transit_entry.name)
|
||||
partial_entry.to_warehouse = target_warehouse
|
||||
partial_entry.items[0].qty = 4
|
||||
partial_entry.items[0].t_warehouse = target_warehouse
|
||||
partial_entry.save().submit()
|
||||
|
||||
remaining_entry = make_stock_in_entry(transit_entry.name)
|
||||
self.assertEqual(remaining_entry.items[0].uom, "Kg")
|
||||
self.assertEqual(remaining_entry.items[0].qty, 6)
|
||||
|
||||
remaining_entry.to_warehouse = target_warehouse
|
||||
remaining_entry.items[0].t_warehouse = target_warehouse
|
||||
remaining_entry.save().submit()
|
||||
|
||||
self.assertFalse(make_stock_in_entry(transit_entry.name).get("items"))
|
||||
|
||||
def test_end_transit_maps_smallest_remaining_qty(self):
|
||||
"""The smallest storable remainder survives binary subtraction, 2.001 - 2 is 0.0009999999999998899."""
|
||||
company = "_Test Company"
|
||||
source_warehouse = "_Test Warehouse - _TC"
|
||||
target_warehouse = "_Test Warehouse 1 - _TC"
|
||||
transit_warehouse = get_in_transit_warehouse(company)
|
||||
|
||||
item_code = make_item(
|
||||
"_Test Transit Fractional Item", {"is_stock_item": 1, "stock_uom": "Litre"}
|
||||
).name
|
||||
smallest_qty = 1 / (10 ** frappe.get_precision("Stock Entry Detail", "transfer_qty"))
|
||||
|
||||
make_stock_entry(item_code=item_code, target=source_warehouse, qty=100, basic_rate=100)
|
||||
|
||||
transit_entry = make_stock_entry(
|
||||
item_code=item_code,
|
||||
source=source_warehouse,
|
||||
target=transit_warehouse,
|
||||
purpose="Material Transfer",
|
||||
add_to_transit=1,
|
||||
qty=2 + smallest_qty,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
partial_entry = make_stock_in_entry(transit_entry.name)
|
||||
partial_entry.to_warehouse = target_warehouse
|
||||
partial_entry.items[0].qty = 2
|
||||
partial_entry.items[0].t_warehouse = target_warehouse
|
||||
partial_entry.save().submit()
|
||||
|
||||
remaining_entry = make_stock_in_entry(transit_entry.name)
|
||||
self.assertEqual(remaining_entry.items[0].qty, smallest_qty)
|
||||
|
||||
def test_add_to_transit_non_transit_target_warehouse_validation(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
item_code = "_Test Transit Item 2"
|
||||
company = "_Test Company"
|
||||
|
||||
create_warehouse("Test Source Warehouse")
|
||||
create_warehouse("Test Regular Target Warehouse")
|
||||
|
||||
create_item(
|
||||
item_code=item_code,
|
||||
is_stock_item=1,
|
||||
is_purchase_item=1,
|
||||
company=company,
|
||||
)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target="Test Source Warehouse - _TC",
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
expense_account="Stock Adjustment - _TC",
|
||||
cost_center="Main - _TC",
|
||||
)
|
||||
|
||||
# Submitting or saving with add_to_transit=1 and a non-transit target warehouse must be rejected
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.purpose = "Material Transfer"
|
||||
se.stock_entry_type = "Material Transfer"
|
||||
se.company = company
|
||||
se.from_warehouse = "Test Source Warehouse - _TC"
|
||||
se.to_warehouse = "Test Regular Target Warehouse - _TC"
|
||||
se.add_to_transit = 1
|
||||
se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code,
|
||||
"s_warehouse": "Test Source Warehouse - _TC",
|
||||
"t_warehouse": "Test Regular Target Warehouse - _TC",
|
||||
"qty": 5,
|
||||
"basic_rate": 100,
|
||||
"expense_account": "Stock Adjustment - _TC",
|
||||
"cost_center": "Main - _TC",
|
||||
},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, se.save)
|
||||
|
||||
def test_material_receipt_gl_entry(self):
|
||||
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_in
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
combine_datetime,
|
||||
get_available_serial_nos,
|
||||
get_serial_nos_based_on_posting_date,
|
||||
)
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.doctype.stock_reconciliation_item.stock_reconciliation_item import StockReconciliationItem
|
||||
@@ -487,37 +488,65 @@ class StockReconciliation(StockController):
|
||||
reco_obj = cls_obj.duplicate_package()
|
||||
|
||||
total_current_qty = 0.0
|
||||
entries_in_stock = []
|
||||
serial_nos_in_stock = self.get_serial_nos_in_stock(row, reco_obj.entries)
|
||||
|
||||
for entry in reco_obj.entries:
|
||||
if not entry.batch_no or entry.serial_no:
|
||||
total_current_qty += entry.qty
|
||||
entry.qty *= -1
|
||||
continue
|
||||
if entry.serial_no not in serial_nos_in_stock:
|
||||
continue
|
||||
|
||||
current_qty = get_batch_qty(
|
||||
entry.batch_no,
|
||||
row.warehouse,
|
||||
row.item_code,
|
||||
ignore_voucher_nos=[self.name],
|
||||
posting_date=self.posting_date,
|
||||
posting_time=self.posting_time,
|
||||
for_stock_levels=True,
|
||||
consider_negative_batches=True,
|
||||
do_not_check_future_batches=True,
|
||||
)
|
||||
current_qty = entry.qty
|
||||
else:
|
||||
current_qty = get_batch_qty(
|
||||
entry.batch_no,
|
||||
row.warehouse,
|
||||
row.item_code,
|
||||
ignore_voucher_nos=[self.name],
|
||||
posting_date=self.posting_date,
|
||||
posting_time=self.posting_time,
|
||||
for_stock_levels=True,
|
||||
consider_negative_batches=True,
|
||||
do_not_check_future_batches=True,
|
||||
)
|
||||
|
||||
if not current_qty:
|
||||
continue
|
||||
if not current_qty:
|
||||
continue
|
||||
|
||||
total_current_qty += current_qty
|
||||
entry.qty = current_qty * -1
|
||||
entries_in_stock.append(entry)
|
||||
|
||||
if total_current_qty:
|
||||
reco_obj.set("entries", entries_in_stock)
|
||||
reco_obj.save()
|
||||
|
||||
row.current_qty = total_current_qty
|
||||
|
||||
return reco_obj
|
||||
|
||||
def get_serial_nos_in_stock(self, row, entries) -> set:
|
||||
"""Serial nos of the row that hold stock in the warehouse as of the posting datetime."""
|
||||
serial_nos = [entry.serial_no for entry in entries if entry.serial_no]
|
||||
if not serial_nos:
|
||||
return set()
|
||||
|
||||
in_stock = get_serial_nos_based_on_posting_date(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.warehouse,
|
||||
"posting_datetime": combine_datetime(self.posting_date, self.posting_time),
|
||||
"serial_nos": serial_nos,
|
||||
"check_serial_nos": True,
|
||||
"voucher_no": self.name,
|
||||
}
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
return set(in_stock)
|
||||
|
||||
def has_change_in_serial_batch(self, row) -> bool:
|
||||
bundles = {row.serial_and_batch_bundle: [], row.current_serial_and_batch_bundle: []}
|
||||
|
||||
@@ -948,7 +977,19 @@ class StockReconciliation(StockController):
|
||||
)
|
||||
)
|
||||
|
||||
def get_stranded_stock_value(self, row) -> float:
|
||||
def get_balance_before_reconciliation(self, row) -> dict:
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
|
||||
return get_previous_sle(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.warehouse,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
}
|
||||
)
|
||||
|
||||
def get_stranded_stock_value(self, row, previous_sle=None) -> float:
|
||||
"""Stock value the ledger still carries for an item-warehouse that has no quantity on hand.
|
||||
|
||||
This is what an adjustment entry writes off. The write-off is measured at item-warehouse
|
||||
@@ -957,16 +998,10 @@ class StockReconciliation(StockController):
|
||||
at an already empty batch while other batches of the same item still hold stock would
|
||||
otherwise write off the valuation of the stock that remains.
|
||||
"""
|
||||
from erpnext.stock.stock_ledger import get_previous_sle, get_stock_value_difference
|
||||
from erpnext.stock.stock_ledger import get_stock_value_difference
|
||||
|
||||
previous_sle = get_previous_sle(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.warehouse,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
}
|
||||
)
|
||||
if previous_sle is None:
|
||||
previous_sle = self.get_balance_before_reconciliation(row)
|
||||
|
||||
if flt(previous_sle.get("qty_after_transaction")):
|
||||
return 0.0
|
||||
@@ -976,13 +1011,23 @@ class StockReconciliation(StockController):
|
||||
)
|
||||
|
||||
def make_adjustment_entry(self, row, sl_entries):
|
||||
difference_amount = self.get_stranded_stock_value(row)
|
||||
previous_sle = self.get_balance_before_reconciliation(row)
|
||||
difference_amount = self.get_stranded_stock_value(row, previous_sle=previous_sle)
|
||||
|
||||
if not difference_amount:
|
||||
# rounded, so float dust does not post an entry whose GL counterpart rounds away to zero
|
||||
if not flt(difference_amount, self.precision("difference_amount")):
|
||||
return
|
||||
|
||||
args = self.get_sle_for_items(row)
|
||||
args.update({"stock_value_difference": -1 * difference_amount, "is_adjustment_entry": 1})
|
||||
args.update(
|
||||
{
|
||||
"stock_value_difference": -1 * difference_amount,
|
||||
# the row carries no rate, so carry the running one forward rather than stamp a zero
|
||||
# that later rate lookups would read back as the last known valuation
|
||||
"valuation_rate": flt(previous_sle.get("valuation_rate")),
|
||||
"is_adjustment_entry": 1,
|
||||
}
|
||||
)
|
||||
|
||||
sl_entries.append(args)
|
||||
|
||||
@@ -1045,7 +1090,16 @@ class StockReconciliation(StockController):
|
||||
has_dimensions = True
|
||||
|
||||
if self.docstatus == 2:
|
||||
if row.current_qty and current_bundle:
|
||||
if self.is_adjustment_row(row):
|
||||
# Reversing a value-only entry must not shift any quantity, so mirror the balance the
|
||||
# ledger carried across it and let get_stock_reco_qty_shift resolve to zero.
|
||||
data.actual_qty = 0.0
|
||||
data.qty_after_transaction = flt(row.current_qty)
|
||||
data.previous_qty_after_transaction = flt(row.current_qty)
|
||||
data.valuation_rate = flt(row.current_valuation_rate)
|
||||
data.stock_value = flt(row.current_amount)
|
||||
data.stock_value_difference = -1 * flt(row.amount_difference)
|
||||
elif row.current_qty and current_bundle:
|
||||
data.actual_qty = -1 * row.current_qty
|
||||
data.qty_after_transaction = flt(row.current_qty)
|
||||
data.previous_qty_after_transaction = flt(row.qty)
|
||||
@@ -1178,9 +1232,14 @@ class StockReconciliation(StockController):
|
||||
|
||||
for row in self.items:
|
||||
stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name))
|
||||
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
|
||||
|
||||
if self.is_adjustment_row(row):
|
||||
self.set_adjustment_row_values(row, amount_difference)
|
||||
difference_amount += amount_difference
|
||||
continue
|
||||
|
||||
amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount"))
|
||||
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
|
||||
current_amount = flt(amount - amount_difference, row.precision("current_amount"))
|
||||
|
||||
current_qty = self.get_current_qty_from_ledger(row)
|
||||
@@ -1210,6 +1269,50 @@ class StockReconciliation(StockController):
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
def is_adjustment_row(self, row: StockReconciliationItem) -> bool:
|
||||
# Read once for the whole voucher: both callers run per row, and a reconciliation
|
||||
# submits and cancels synchronously for up to 100 of them.
|
||||
if self.flags.adjustment_rows is None:
|
||||
self.flags.adjustment_rows = set(
|
||||
frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_no": self.name,
|
||||
"is_adjustment_entry": 1,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
pluck="voucher_detail_no",
|
||||
)
|
||||
)
|
||||
|
||||
return row.name in self.flags.adjustment_rows
|
||||
|
||||
def set_adjustment_row_values(self, row: StockReconciliationItem, amount_difference: float):
|
||||
"""Refresh a value-only row: it moves no stock, so both sides carry the ledger's own figures
|
||||
and ``amount_difference`` is the write-off booked to the GL, not a change in what is on hand.
|
||||
"""
|
||||
previous_sle = self.get_previous_ledger_entry(row) or frappe._dict()
|
||||
|
||||
current_qty = flt(previous_sle.get("qty_after_transaction"), row.precision("current_qty"))
|
||||
current_valuation_rate = flt(
|
||||
previous_sle.get("valuation_rate"), row.precision("current_valuation_rate")
|
||||
)
|
||||
# from the ledger's stock value, since rounding the rate first loses money on large qtys
|
||||
current_amount = flt(previous_sle.get("stock_value"), row.precision("current_amount"))
|
||||
|
||||
row.db_set(
|
||||
{
|
||||
"amount": current_amount,
|
||||
"current_qty": current_qty,
|
||||
"current_valuation_rate": current_valuation_rate,
|
||||
"current_amount": current_amount,
|
||||
"quantity_difference": 0.0,
|
||||
"amount_difference": amount_difference,
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
def get_current_qty_from_ledger(self, row: StockReconciliationItem):
|
||||
"""Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting.
|
||||
|
||||
@@ -1224,6 +1327,14 @@ class StockReconciliation(StockController):
|
||||
)
|
||||
return abs(flt(total_qty, row.precision("current_qty")))
|
||||
|
||||
previous_sle = self.get_previous_ledger_entry(row)
|
||||
if previous_sle is None:
|
||||
return flt(row.current_qty, row.precision("current_qty"))
|
||||
|
||||
return flt(previous_sle.get("qty_after_transaction"), row.precision("current_qty"))
|
||||
|
||||
def get_previous_ledger_entry(self, row: StockReconciliationItem):
|
||||
"""Balance, rate and value carried just before this row's own entries, or None if it has none."""
|
||||
reco_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
@@ -1236,12 +1347,12 @@ class StockReconciliation(StockController):
|
||||
as_dict=True,
|
||||
)
|
||||
if not reco_sle:
|
||||
return flt(row.current_qty, row.precision("current_qty"))
|
||||
return None
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
previous_sle = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.qty_after_transaction)
|
||||
.select(sle.qty_after_transaction, sle.valuation_rate, sle.stock_value)
|
||||
.where(
|
||||
(sle.item_code == row.item_code)
|
||||
& (sle.warehouse == row.warehouse)
|
||||
@@ -1257,9 +1368,9 @@ class StockReconciliation(StockController):
|
||||
.orderby(sle.posting_datetime, order=frappe.qb.desc)
|
||||
.orderby(sle.creation, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
).run()
|
||||
).run(as_dict=True)
|
||||
|
||||
return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0
|
||||
return previous_sle[0] if previous_sle else frappe._dict()
|
||||
|
||||
def submit(self):
|
||||
if len(self.items) > 100:
|
||||
|
||||
@@ -1324,6 +1324,39 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
self.assertAlmostEqual(row.incoming_rate, 1000.00)
|
||||
self.assertEqual(row.serial_no, serial_nos[row.idx - 1])
|
||||
|
||||
def test_opening_stock_reco_for_serial_nos_without_stock(self):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import make_serial_nos
|
||||
|
||||
item = self.make_item(
|
||||
"Test Serial No Item Opening Stock Not Reconcile All",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_serial_no": 1,
|
||||
"serial_no_series": "SNN-TEST-OPENING-NRALL-S-.###",
|
||||
},
|
||||
)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
serial_nos = [f"SNN-TEST-OPENING-NRALL-{idx}" for idx in range(1, 6)]
|
||||
make_serial_nos(item.name, [{"serial_no": serial_no} for serial_no in serial_nos])
|
||||
|
||||
with self.change_settings("Stock Settings", {"allow_negative_stock": 0}):
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item.name,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=100,
|
||||
purpose="Opening Stock",
|
||||
expense_account="Temporary Opening - _TC",
|
||||
reconcile_all_serial_batch=0,
|
||||
serial_no=serial_nos,
|
||||
)
|
||||
|
||||
self.assertEqual(sr.docstatus, 1)
|
||||
self.assertEqual(sr.items[0].current_qty, 0)
|
||||
self.assertFalse(sr.items[0].current_serial_and_batch_bundle)
|
||||
self.assertEqual(get_stock_balance(item.name, warehouse), 5)
|
||||
|
||||
def test_stock_reco_with_legacy_batch(self):
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
|
||||
@@ -2343,6 +2376,211 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
self.assertEqual(sles[0].qty_after_transaction, 0)
|
||||
self.assertEqual(flt(sles[0].stock_value_difference), -100.0)
|
||||
|
||||
def test_adjustment_entry_clears_value_stranded_at_zero_qty(self):
|
||||
"""bal_qty 0 with bal_val 500: the write-off has to bring the reported value to zero."""
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
from erpnext.stock.report.stock_balance.stock_balance import execute
|
||||
|
||||
item_code = self.make_item("Test Stock Reco Stranded Value Non Batch").name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(nowdate(), -3),
|
||||
)
|
||||
make_stock_entry(item_code=item_code, source=warehouse, qty=10, posting_date=add_days(nowdate(), -2))
|
||||
|
||||
# strand 500 of value: qty nets out, stock_value_difference does not
|
||||
receipt_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry", {"voucher_no": receipt.name, "is_cancelled": 0}, "name"
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
receipt_sle,
|
||||
"stock_value_difference",
|
||||
flt(frappe.db.get_value("Stock Ledger Entry", receipt_sle, "stock_value_difference")) + 500,
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
report_filters = frappe._dict(
|
||||
{"item_code": [item_code], "warehouse": [warehouse], "company": "_Test Company"}
|
||||
)
|
||||
|
||||
# this is what the user sees before the reconciliation
|
||||
_columns, data = execute(filters=report_filters)
|
||||
self.assertEqual(flt(data[0].get("bal_qty")), 0.0)
|
||||
self.assertEqual(flt(data[0].get("bal_val")), 500.0)
|
||||
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
|
||||
)
|
||||
sr.items[0].allow_zero_valuation_rate = 1
|
||||
sr.save()
|
||||
sr.submit()
|
||||
|
||||
sles = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": sr.name, "is_cancelled": 0},
|
||||
fields=["actual_qty", "qty_after_transaction", "stock_value_difference", "is_adjustment_entry"],
|
||||
)
|
||||
|
||||
self.assertEqual(len(sles), 1)
|
||||
self.assertEqual(sles[0].is_adjustment_entry, 1)
|
||||
self.assertEqual(flt(sles[0].actual_qty), 0.0)
|
||||
self.assertEqual(flt(sles[0].qty_after_transaction), 0.0)
|
||||
self.assertEqual(flt(sles[0].stock_value_difference), -500.0)
|
||||
|
||||
# the report, and the GL basis behind it, both land on zero
|
||||
# (the row drops out entirely once every figure on it is zero)
|
||||
_columns, data = execute(filters=report_filters)
|
||||
self.assertEqual(flt(data[0].get("bal_val")) if data else 0.0, 0.0)
|
||||
self.assertEqual(
|
||||
flt(get_stock_value_on(warehouses=warehouse, posting_date=nowdate(), item_code=item_code)),
|
||||
0.0,
|
||||
)
|
||||
|
||||
def _make_backdated_adjustment_scenario(self, item_name, valuation_method, backdated_qty=4):
|
||||
"""Strand 100 of value at zero qty, write it off, then backdate a receipt before the write-off."""
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
item_code = self.make_item(item_name, {"valuation_method": valuation_method}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(nowdate(), -10),
|
||||
)
|
||||
make_stock_entry(item_code=item_code, source=warehouse, qty=10, posting_date=add_days(nowdate(), -9))
|
||||
|
||||
# strand 100 of value on the ledger: qty nets out, stock_value_difference does not
|
||||
receipt_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry", {"voucher_no": receipt.name, "is_cancelled": 0}, "name"
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
receipt_sle,
|
||||
"stock_value_difference",
|
||||
flt(frappe.db.get_value("Stock Ledger Entry", receipt_sle, "stock_value_difference")) + 100,
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=0,
|
||||
rate=0,
|
||||
posting_date=add_days(nowdate(), -5),
|
||||
do_not_save=1,
|
||||
)
|
||||
sr.items[0].allow_zero_valuation_rate = 1
|
||||
sr.save()
|
||||
sr.submit()
|
||||
|
||||
self.assertTrue(
|
||||
frappe.db.exists("Stock Ledger Entry", {"voucher_no": sr.name, "is_adjustment_entry": 1})
|
||||
)
|
||||
|
||||
# a backdated receipt lands before the write-off
|
||||
if backdated_qty:
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=backdated_qty,
|
||||
basic_rate=50,
|
||||
posting_date=add_days(nowdate(), -7),
|
||||
)
|
||||
|
||||
return item_code, warehouse, sr
|
||||
|
||||
def _assert_backdated_stock_survives(self, item_code, warehouse, sr):
|
||||
adjustment_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": sr.name, "is_cancelled": 0},
|
||||
["qty_after_transaction", "stock_value", "stock_value_difference", "valuation_rate"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
# the backdated stock is carried through the adjustment entry, not wiped out by it
|
||||
self.assertEqual(flt(adjustment_sle.qty_after_transaction), 4.0)
|
||||
self.assertEqual(flt(adjustment_sle.stock_value), 200.0)
|
||||
self.assertEqual(flt(adjustment_sle.valuation_rate), 50.0)
|
||||
|
||||
# and the write-off still lands the running ledger value on the stock value it holds
|
||||
self.assertEqual(
|
||||
flt(get_stock_value_on(warehouses=warehouse, posting_date=nowdate(), item_code=item_code)),
|
||||
200.0,
|
||||
)
|
||||
self.assertEqual(get_stock_balance(item_code, warehouse), 4.0)
|
||||
|
||||
def test_adjustment_entry_does_not_zero_out_backdated_stock(self):
|
||||
"""An adjustment entry restates value, so a backdated receipt posted before it must survive."""
|
||||
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
|
||||
"Test Stock Reco Backdated Adjustment", "FIFO"
|
||||
)
|
||||
self._assert_backdated_stock_survives(item_code, warehouse, sr)
|
||||
|
||||
def test_adjustment_entry_does_not_zero_out_backdated_stock_moving_average(self):
|
||||
"""Same, through the moving average path rather than the queue."""
|
||||
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
|
||||
"Test Stock Reco Backdated Adjustment MA", "Moving Average"
|
||||
)
|
||||
self._assert_backdated_stock_survives(item_code, warehouse, sr)
|
||||
|
||||
def test_adjustment_row_amount_is_not_distorted_by_rate_rounding(self):
|
||||
"""The refreshed amount comes from the ledger's stock value, not from a rounded rate."""
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
|
||||
"Test Stock Reco Adjustment Rounding", "FIFO", backdated_qty=0
|
||||
)
|
||||
|
||||
# a backdated receipt whose value does not divide evenly into a 2 decimal rate
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=10000,
|
||||
basic_rate=1.2345,
|
||||
posting_date=add_days(nowdate(), -7),
|
||||
)
|
||||
|
||||
sr.reload()
|
||||
row = sr.items[0]
|
||||
|
||||
self.assertEqual(flt(row.current_qty), 10000.0)
|
||||
self.assertEqual(flt(row.current_amount), 12345.0)
|
||||
|
||||
def test_cancelling_adjustment_entry_shifts_no_qty(self):
|
||||
"""Reversing a value-only entry must not push the preserved quantity into later entries."""
|
||||
from erpnext.stock.stock_ledger import get_stock_reco_qty_shift
|
||||
|
||||
_item_code, _warehouse, sr = self._make_backdated_adjustment_scenario(
|
||||
"Test Stock Reco Adjustment Cancel", "FIFO"
|
||||
)
|
||||
|
||||
sr.reload()
|
||||
row = sr.items[0]
|
||||
|
||||
# the refreshed document reports the balance the ledger carries and no quantity movement
|
||||
self.assertEqual(flt(row.current_qty), 4.0)
|
||||
self.assertEqual(flt(row.quantity_difference), 0.0)
|
||||
self.assertEqual(flt(row.current_valuation_rate), 50.0)
|
||||
self.assertEqual(flt(row.current_amount), 200.0)
|
||||
self.assertEqual(flt(row.amount_difference), -100.0)
|
||||
|
||||
# the reversal built on cancellation moves nothing, so later entries are not shifted
|
||||
sr.docstatus = 2
|
||||
args = sr.get_sle_for_items(row)
|
||||
args.actual_qty = -flt(args.actual_qty) # as make_sl_entries flips it for a cancellation
|
||||
|
||||
self.assertEqual(flt(args.actual_qty), 0.0)
|
||||
self.assertEqual(flt(get_stock_reco_qty_shift(args)), 0.0)
|
||||
|
||||
|
||||
def create_batch_item_with_batch(item_name, batch_id):
|
||||
batch_item_doc = create_item(item_name, is_stock_item=1)
|
||||
|
||||
@@ -109,30 +109,63 @@ class SerialBatchBundle:
|
||||
):
|
||||
return True
|
||||
|
||||
def make_serial_batch_no_bundle_for_material_transfer(self):
|
||||
from erpnext.controllers.stock_controller import make_bundle_for_material_transfer
|
||||
def get_transit_package(self) -> str | None:
|
||||
if self.sle.is_cancelled or self.sle.voucher_type not in ["Delivery Note", "Sales Invoice"]:
|
||||
return None
|
||||
|
||||
bundle = frappe.db.get_value(
|
||||
"Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle"
|
||||
return frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_no": self.sle.voucher_no,
|
||||
"voucher_detail_no": self.sle.voucher_detail_no,
|
||||
"item_code": self.sle.item_code,
|
||||
"actual_qty": ("<", 0),
|
||||
"is_cancelled": 0,
|
||||
"serial_and_batch_bundle": ("is", "set"),
|
||||
},
|
||||
"serial_and_batch_bundle",
|
||||
order_by="creation desc",
|
||||
)
|
||||
|
||||
if bundle:
|
||||
new_bundle_id = make_bundle_for_material_transfer(
|
||||
is_new=False,
|
||||
docstatus=1,
|
||||
voucher_type=self.sle.voucher_type,
|
||||
voucher_no=self.sle.voucher_no,
|
||||
serial_and_batch_bundle=bundle,
|
||||
warehouse=self.sle.warehouse,
|
||||
type_of_transaction="Inward" if self.sle.actual_qty > 0 else "Outward",
|
||||
do_not_submit=0,
|
||||
)
|
||||
self.sle.db_set({"serial_and_batch_bundle": new_bundle_id})
|
||||
def make_serial_batch_no_bundle_for_material_transfer(self, bundle):
|
||||
from erpnext.controllers.stock_controller import make_bundle_for_material_transfer
|
||||
|
||||
if not bundle:
|
||||
return
|
||||
|
||||
new_bundle_id = make_bundle_for_material_transfer(
|
||||
is_new=False,
|
||||
docstatus=1,
|
||||
voucher_type=self.sle.voucher_type,
|
||||
voucher_no=self.sle.voucher_no,
|
||||
serial_and_batch_bundle=bundle,
|
||||
warehouse=self.sle.warehouse,
|
||||
type_of_transaction="Inward" if self.sle.actual_qty > 0 else "Outward",
|
||||
do_not_submit=0,
|
||||
)
|
||||
self.sle.db_set({"serial_and_batch_bundle": new_bundle_id})
|
||||
|
||||
def make_serial_batch_no_bundle(self):
|
||||
if self.sle.actual_qty > 0 and (transit_package := self.get_transit_package()):
|
||||
self.make_serial_batch_no_bundle_for_material_transfer(transit_package)
|
||||
|
||||
if not self.is_packed_entry():
|
||||
frappe.db.set_value(
|
||||
self.child_doctype,
|
||||
self.sle.voucher_detail_no,
|
||||
"serial_and_batch_bundle",
|
||||
self.sle.serial_and_batch_bundle,
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
self.validate_item()
|
||||
if self.sle.actual_qty > 0 and self.is_material_transfer():
|
||||
self.make_serial_batch_no_bundle_for_material_transfer()
|
||||
self.make_serial_batch_no_bundle_for_material_transfer(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
sn_doc = SerialBatchCreation(
|
||||
@@ -313,6 +346,7 @@ class SerialBatchBundle:
|
||||
)
|
||||
and self.sle.actual_qty < 0
|
||||
)
|
||||
or (self.sle.actual_qty > 0 and self.get_transit_package())
|
||||
)
|
||||
):
|
||||
self.make_serial_batch_no_bundle()
|
||||
|
||||
@@ -28,13 +28,6 @@ from frappe.utils import (
|
||||
import erpnext
|
||||
from erpnext.stock.doctype.bin.bin import update_qty_from_sle
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_auto_batch_nos,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_batch_nos_details,
|
||||
get_sre_reserved_serial_nos_details,
|
||||
)
|
||||
from erpnext.stock.utils import (
|
||||
get_combine_datetime,
|
||||
get_incoming_outgoing_rate_for_cancel,
|
||||
@@ -959,8 +952,10 @@ class update_entries_after:
|
||||
|
||||
def process_sle_against_current_timestamp(self):
|
||||
sl_entries = get_sle_against_current_voucher(self.args)
|
||||
if self.args.get("cancelled") and sl_entries:
|
||||
self.seed_previous_sle_for_cancellation(sl_entries[0])
|
||||
if self.args.get("cancelled"):
|
||||
# Cancellation flags every entry of the voucher first, so this query usually returns
|
||||
# nothing and the args are the only anchor left to seed the previous values from.
|
||||
self.seed_previous_sle_for_cancellation(sl_entries[0] if sl_entries else self.args)
|
||||
for sle in sl_entries:
|
||||
sle["timestamp"] = sle.posting_datetime
|
||||
self.process_sle(sle)
|
||||
@@ -971,7 +966,7 @@ class update_entries_after:
|
||||
return
|
||||
|
||||
args = frappe._dict(anchor_sle)
|
||||
args["sle_id"] = args.name
|
||||
args["sle_id"] = args.get("name")
|
||||
prev_sle = get_previous_sle_of_current_voucher(args)
|
||||
if prev_sle:
|
||||
self.prev_sle_dict[key] = prev_sle
|
||||
@@ -1111,6 +1106,8 @@ class update_entries_after:
|
||||
else:
|
||||
if (
|
||||
sle.voucher_type == "Stock Reconciliation"
|
||||
# an adjustment entry counted nothing, so it must not assert a balance
|
||||
and not sle.is_adjustment_entry
|
||||
and not sle.batch_no
|
||||
and not sle.has_batch_no
|
||||
and not has_dimensions
|
||||
@@ -1172,26 +1169,21 @@ class update_entries_after:
|
||||
|
||||
sle.stock_value_difference = stock_value_difference
|
||||
|
||||
if (
|
||||
sle.is_adjustment_entry
|
||||
and flt(sle.qty_after_transaction, self.flt_precision) == 0
|
||||
and (
|
||||
flt(sle.stock_value, self.currency_precision) != 0
|
||||
or flt(sle.stock_value_difference, self.currency_precision) == 0
|
||||
)
|
||||
):
|
||||
sle.stock_value_difference = (
|
||||
get_stock_value_difference(
|
||||
sle.item_code,
|
||||
sle.warehouse,
|
||||
sle.posting_date,
|
||||
sle.posting_time,
|
||||
voucher_detail_no=sle.voucher_detail_no,
|
||||
creation=sle.creation,
|
||||
)
|
||||
* -1
|
||||
# Re-derive the write-off on every repost: whatever brings the running sum of
|
||||
# stock_value_difference back in line with the stock value held at this point. A non-zero
|
||||
# difference above means the entry moved something, so it is not a write-off and is left alone.
|
||||
if sle.is_adjustment_entry and flt(sle.stock_value_difference, self.currency_precision) == 0:
|
||||
value_till_now = get_stock_value_difference(
|
||||
sle.item_code,
|
||||
sle.warehouse,
|
||||
sle.posting_date,
|
||||
sle.posting_time,
|
||||
voucher_detail_no=sle.voucher_detail_no,
|
||||
creation=sle.creation,
|
||||
)
|
||||
|
||||
sle.stock_value_difference = flt(flt(sle.stock_value) - value_till_now, self.currency_precision)
|
||||
|
||||
sle.doctype = "Stock Ledger Entry"
|
||||
sle.modified = now()
|
||||
frappe.get_doc(sle).db_update()
|
||||
@@ -2587,51 +2579,6 @@ def validate_reserved_stock(kwargs):
|
||||
frappe.throw(msg, title=_("Reserved Stock"))
|
||||
|
||||
|
||||
def validate_reserved_serial_nos(item_code, warehouse, serial_nos):
|
||||
if reserved_serial_nos_details := get_sre_reserved_serial_nos_details(item_code, warehouse, serial_nos):
|
||||
if common_serial_nos := list(set(serial_nos).intersection(set(reserved_serial_nos_details.keys()))):
|
||||
msg = _(
|
||||
"Serial Nos are reserved in Stock Reservation Entries, you need to unreserve them before proceeding."
|
||||
)
|
||||
msg += "<br />"
|
||||
msg += _("Example: Serial No {0} reserved in {1}.").format(
|
||||
frappe.bold(common_serial_nos[0]),
|
||||
frappe.get_desk_link(
|
||||
"Stock Reservation Entry", reserved_serial_nos_details[common_serial_nos[0]]
|
||||
),
|
||||
)
|
||||
frappe.throw(msg, title=_("Reserved Serial No."))
|
||||
|
||||
|
||||
def validate_reserved_batch_nos(item_code, warehouse, batch_nos):
|
||||
if reserved_batches_map := get_sre_reserved_batch_nos_details(item_code, warehouse, batch_nos):
|
||||
available_batches = get_auto_batch_nos(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"posting_datetime": get_combine_datetime(nowdate(), nowtime()),
|
||||
}
|
||||
)
|
||||
)
|
||||
available_batches_map = {row.batch_no: row.qty for row in available_batches}
|
||||
precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
|
||||
for batch_no in batch_nos:
|
||||
diff = flt(
|
||||
available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision
|
||||
)
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format(
|
||||
abs(diff),
|
||||
frappe.get_desk_link("Batch", batch_no),
|
||||
frappe.get_desk_link("Warehouse", warehouse),
|
||||
nowdate(),
|
||||
nowtime(),
|
||||
)
|
||||
frappe.throw(msg, title=_("Reserved Stock for Batch"))
|
||||
|
||||
|
||||
def is_negative_stock_allowed(*, item_code: str | None = None) -> bool:
|
||||
if frappe.get_cached_doc("Stock Settings").allow_negative_stock:
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user