Compare commits

..

22 Commits

Author SHA1 Message Date
Mihir Kandoi
3bf3bc709f fix(stock): carry the batch of a delivery into the in-transit warehouse (#59264) 2026-09-22 18:17:14 +05:30
Pandiyan P
102842253b fix(stock): use net purchase values in landed cost vouchers (#59274)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 18:05:16 +05:30
Sudharsanan Ashok
e814d13126 fix(accounts): keep price list within user permissions (#59231)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 18:04:26 +05:30
Sudharsanan Ashok
7f608f31eb fix(stock): keep mapped cost center in stock entry items (#59286) 2026-09-22 18:02:53 +05:30
Sudharsanan Ashok
f990df9deb refactor(selling): remove ensure delivery based on produced serial no (#59217)
* refactor(selling): remove ensure delivery based on produced serial no

the ensure_delivery_based_on_produced_serial_no checkbox on sales order
item only ever validated itself at sales order save: serialized item,
active bom, and the same setting on every row of an item. nothing
downstream read the flag, so a delivery note or stock updating sales
invoice could ship any serial no and submit silently. the field promised
a guarantee it never enforced.

remove the field, its typing entry and validate_serial_no_based_delivery.
stock reservation on the sales order and work order is the supported way
to hold produced stock for an order. the database column is left in place
by migrate, so no data is dropped.

* chore(stock): remove dead reserved serial and batch no validators

validate_reserved_serial_nos and validate_reserved_batch_nos lost their
only callers in a20951e1cd ("fix: reserved serial nos validation"), which
moved serial level reservation checks into serial and batch bundle but
left both functions in stock ledger. drop them along with the three
imports that only they used.
2026-09-22 17:54:54 +05:30
Sudharsanan Ashok
62cb38d36b fix(stock): seed bin values when cancelling a stock voucher (#59250)
* fix(stock): seed bin values when cancelling a stock voucher

cancellation flags every sle of the voucher before update_entries_after
runs, so get_sle_against_current_voucher returns nothing and the seeding
added in #57380 never fires. prev_sle_dict stays empty, update_bin()
writes nothing, and the bin keeps the stock value and valuation rate it
had before the cancellation while its quantity is restored.

seed from the args when the query comes back empty, leaving the existing
anchor in place whenever a live entry shares the posting datetime.

* test(stock): cover bin stock value after cancelling a transfer

a transfer between two warehouses that both hold stock, then cancelled:
both bins must return to their previous quantity, valuation rate and
stock value. fails on develop with 500.0 != 1000.
2026-09-22 17:53:07 +05:30
Mihir Kandoi
8d6ee86ba3 fix: don't bump Customer timestamp when caching primary address (#59285)
Saving a Customer runs create_primary_address, which calls frappe.set_value on the
linked Address to ensure is_primary_address. That is a full document save, so
ERPNextAddress.on_update fires and writes the address display back to
Customer.primary_address with update_modified=True. The Customer row's modified
moves after the document has already been written, so the form keeps the older
timestamp and the next save from the same form fails check_if_latest with
TimestampMismatchError.

primary_address is a denormalized display cache, not a user edit, so writing it
must not move the optimistic lock timestamp.
2026-09-22 12:10:21 +00:00
Khushi Rawat
e3fc1790d4 Merge pull request #59262 from aerele/fix-reverse-je-mandatory-fields
fix(journal-entry): carry reference fields into reverse journal entry
2026-09-22 17:15:30 +05:30
rohitwaghchaure
719b53f2ad fix: do not zero out backdated stock at a stock reco adjustment entry (#59269)
* fix: do not zero out backdated stock at a stock reco adjustment entry

* fix: keep a stock reco adjustment entry value-only on cancel and refresh

* fix: read stock reco adjustment rows once and value them from the ledger
2026-09-22 16:38:51 +05:30
Pandiyan P
79fd06a1ba fix(crm): keep opportunity type default untranslated (#59277) 2026-09-22 11:06:45 +00:00
Khushi Rawat
5ebee655b7 Merge pull request #59180 from khushi8112/pos-price-list-exchange-rate
fix: refresh price list exchange rate when currency changes
2026-09-22 15:29:36 +05:30
Mihir Kandoi
9f2b05c103 fix: skip serial nos with no stock when reconciling specific serial nos (#59266) 2026-09-22 14:31:14 +05:30
ervishnucs
9e01992217 fix(journal-entry): carry reference fields into reverse journal entry 2026-09-22 13:25:11 +05:30
Shllokkk
5ebc52c95c Merge pull request #59240 from Shllokkk/asset-depr-schedule-company-filter
fix: asset field unfilterable on manual Asset Depreciation Schedule
2026-09-22 12:36:50 +05:30
ruthra kumar
86eb9c6204 Merge pull request #59230 from ruthra-kumar/more_changes_to_pcv_using_mapreduce
refactor(pcv): status on list view, progress update on UI
2026-09-22 12:19:05 +05:30
Afsal Syed
893fc75df4 fix(stock): apply transit warehouse filter to target warehouse in items table (#59192) 2026-09-22 06:48:07 +00:00
ruthra kumar
f2279a139f refactor(pcv): status on list view, progress update on UI 2026-09-22 11:50:28 +05:30
Mihir Kandoi
30e0382aa9 feat(selling): group Sales Order Analysis by item (#59236) 2026-09-22 11:09:20 +05:30
Mihir Kandoi
d2b1965cc8 fix(stock): compare transit quantities in stock UOM (#59202) 2026-09-22 11:08:09 +05:30
Shllokkk
a931c7d2f4 fix: remove company link filter on asset field in Asset Depreciation Schedule 2026-09-22 03:36:40 +05:30
khushi8112
221baabf9e test: cover price list exchange rate currency changes 2026-09-18 17:47:34 +05:30
khushi8112
8b59d66f94 fix: refresh price list exchange rate when currency changes 2026-09-18 17:34:55 +05:30
35 changed files with 1838 additions and 461 deletions

View File

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

View File

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

View File

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

View File

@@ -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];
},
};

View File

@@ -2624,51 +2624,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
return_pi.submit()
self.assertEqual(return_pi.docstatus, 1)
def test_rejected_material_is_not_valued_on_a_stock_updating_invoice(self):
"""An invoice bills the accepted quantity alone, so its rejected material has no cost and the
stock it moves must match the entries it books."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Rejected Warehouse", company=company)
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 1)
self.addCleanup(
frappe.db.set_single_value, "Buying Settings", "set_valuation_rate_for_rejected_materials", 0
)
pi = make_purchase_invoice(
item_code=item,
company=company,
warehouse="Stores - TCP1",
rejected_warehouse=rejected_warehouse,
cost_center="Main - TCP1",
supplier_warehouse="Work In Progress - TCP1",
expense_account="_Test Account Cost for Goods Sold - TCP1",
update_stock=1,
received_qty=10,
qty=6,
rejected_qty=4,
rate=100,
)
stock_value = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "stock_value_difference"],
)
by_warehouse = {d.warehouse: d.stock_value_difference for d in stock_value}
self.assertEqual(by_warehouse["Stores - TCP1"], 600)
self.assertEqual(by_warehouse[rejected_warehouse], 0)
booked = frappe.get_all(
"GL Entry", filters={"voucher_no": pi.name, "is_cancelled": 0}, fields=["debit"]
)
self.assertEqual(sum(flt(d.debit) for d in booked), sum(by_warehouse.values()))
def test_purchase_invoice_with_use_serial_batch_field_for_rejected_qty(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse

View File

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

View File

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

View File

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

View File

@@ -77,14 +77,3 @@ class BuyingSettings(Document):
def check_maintain_same_rate(self):
if self.maintain_same_rate:
self.set_landed_cost_based_on_purchase_invoice_rate = 0
def is_rejected_material_valued(voucher_type: str) -> bool:
"""Rejected material carries stock value only when something is going to pay for it. A Purchase
Receipt books it against Stock Received But Not Billed, so the supplier still owes an invoice for
it. A stock updating Purchase Invoice bills the accepted quantity alone, so its rejected material
has no cost to carry."""
if voucher_type == "Purchase Invoice":
return False
return bool(frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"))

View File

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

View File

@@ -14,9 +14,6 @@ import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.party import _get_party_details
from erpnext.buying.doctype.buying_settings.buying_settings import (
is_rejected_material_valued,
)
from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
@@ -874,7 +871,7 @@ class BuyingController(SubcontractingController):
if flt(d.rejected_qty) != 0:
valuation_rate_for_rejected_item = 0.0
if is_rejected_material_valued(self.doctype):
if frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
valuation_rate_for_rejected_item = d.valuation_rate
sl_entries.append(

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,9 +27,6 @@ from frappe.utils import (
)
from frappe.utils.csvutils import build_csv_response
from erpnext.buying.doctype.buying_settings.buying_settings import (
is_rejected_material_valued,
)
from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem
from erpnext.stock.serial_batch_bundle import (
BatchNoValuation,
@@ -902,7 +899,9 @@ class SerialandBatchBundle(Document):
if batches and valuation_method == "FIFO":
stock_queue = parse_json(prev_sle.stock_queue)
set_valuation_rate_for_rejected_materials = is_rejected_material_valued(self.voucher_type)
set_valuation_rate_for_rejected_materials = frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate")
for d in self.entries:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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