mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-22 21:07:16 +00:00
Compare commits
2 Commits
develop
...
rejected-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98df30d1da | ||
|
|
6c3046121d |
@@ -47,13 +47,10 @@ class ERPNextAddress(Address):
|
||||
super().on_update()
|
||||
|
||||
address_display = get_address_display(self.as_dict())
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -253,10 +253,6 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Docume
|
||||
|
||||
def post_process(source, target) -> None:
|
||||
target.reversal_of = source.name
|
||||
target.naming_series = source.naming_series
|
||||
if source.voucher_type == "Bank Entry":
|
||||
target.cheque_no = source.cheque_no
|
||||
target.cheque_date = source.cheque_date
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
|
||||
@@ -280,13 +280,12 @@ class PeriodClosingVoucher(AccountsController):
|
||||
|
||||
data = self.get_data_for_mapreduce()
|
||||
mapreduce(
|
||||
"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.mapper",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
|
||||
"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):
|
||||
@@ -331,8 +330,8 @@ class PeriodClosingVoucher(AccountsController):
|
||||
|
||||
def make_gl_entries(self):
|
||||
if frappe.db.estimate_count("GL Entry") > 100_000:
|
||||
frappe.enqueue_task(
|
||||
method=process_gl_and_closing_entries,
|
||||
frappe.enqueue(
|
||||
process_gl_and_closing_entries,
|
||||
doc=self,
|
||||
timeout=1800,
|
||||
)
|
||||
@@ -835,7 +834,7 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
|
||||
return prev_closed_period_end_date
|
||||
|
||||
|
||||
def process_date_range(val):
|
||||
def mapper(val):
|
||||
start_date = val.from_date
|
||||
end_date = val.to_date
|
||||
pcv = val.pcv
|
||||
@@ -882,7 +881,7 @@ def process_date_range(val):
|
||||
return res
|
||||
|
||||
|
||||
def aggregate_partial_result(final, partial_res):
|
||||
def reducer(final, partial_res):
|
||||
if final is None:
|
||||
final = []
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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];
|
||||
},
|
||||
};
|
||||
@@ -216,11 +216,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
if doc.is_internal_supplier and item.valuation_rate:
|
||||
credit_amount = flt(item.valuation_rate * item.stock_qty)
|
||||
|
||||
rejected_amount = self.make_rejected_warehouse_gl_entry(
|
||||
gl_entries, item, voucher_wise_stock_value, inventory_account_map
|
||||
)
|
||||
credit_amount += rejected_amount
|
||||
|
||||
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
@@ -256,10 +251,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
|
||||
else:
|
||||
self.make_rejected_warehouse_gl_entry(
|
||||
gl_entries, item, voucher_wise_stock_value, inventory_account_map
|
||||
)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
@@ -573,49 +564,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
|
||||
return stock_asset_rbnb or item.expense_account
|
||||
|
||||
def make_rejected_warehouse_gl_entry(
|
||||
self, gl_entries, item, voucher_wise_stock_value, inventory_account_map
|
||||
) -> float:
|
||||
"""Book the material the invoice moved into the rejected warehouse.
|
||||
|
||||
An internal transfer carries the value credited out of the in-transit warehouse along with
|
||||
the accepted material, so the entry against it is that warehouse, and the caller credits it
|
||||
for both. On an ordinary invoice the supplier entry already holds the cost.
|
||||
"""
|
||||
doc = self.doc
|
||||
if not (item.rejected_warehouse and flt(item.rejected_qty)):
|
||||
return 0.0
|
||||
|
||||
transfers_rejected_material = doc.is_internal_transfer()
|
||||
|
||||
rejected_amount = flt(
|
||||
voucher_wise_stock_value.get((item.name, item.rejected_warehouse)),
|
||||
item.precision("base_net_amount"),
|
||||
)
|
||||
if not rejected_amount:
|
||||
return 0.0
|
||||
|
||||
rejected_account = doc.get_inventory_account_dict(item, inventory_account_map, "rejected_warehouse")
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": rejected_account["account"],
|
||||
"against": item.expense_account if transfers_rejected_material else doc.supplier,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"debit": rejected_amount,
|
||||
"debit_in_transaction_currency": flt(
|
||||
rejected_amount / doc.conversion_rate, item.precision("net_amount")
|
||||
),
|
||||
},
|
||||
rejected_account["account_currency"],
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
return rejected_amount if transfers_rejected_material else 0.0
|
||||
|
||||
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
|
||||
doc = self.doc
|
||||
net_amt_precision = item.precision("base_net_amount")
|
||||
@@ -629,24 +577,16 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against):
|
||||
net_rate = item.base_net_amount
|
||||
if item.sales_incoming_rate:
|
||||
# Material of a transfer goes back at the rate it came in with, the rejected
|
||||
# material along with the accepted.
|
||||
net_rate = (flt(item.qty) + flt(item.rejected_qty)) * item.sales_incoming_rate
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
|
||||
stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
|
||||
warehouse_debit_amount = flt(
|
||||
voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
|
||||
)
|
||||
|
||||
# The rejected warehouse carries the rest of what the invoice paid for, and is booked
|
||||
# by its own entry, so it is not a variance.
|
||||
returned_stock_value = warehouse_debit_amount + flt(
|
||||
voucher_wise_stock_value.get((item.name, item.rejected_warehouse)), net_amt_precision
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(returned_stock_value, net_amt_precision):
|
||||
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
|
||||
cost_of_goods_sold_account = self.get_stock_variance_account(item)
|
||||
stock_adjustment_amt = stock_amount - returned_stock_value
|
||||
stock_adjustment_amt = stock_amount - warehouse_debit_amount
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
|
||||
@@ -844,9 +844,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0].base_net_amount or 0
|
||||
|
||||
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name, do_not_save=True)
|
||||
pi.credit_to = "_Test Payable USD - _TC"
|
||||
pi.submit()
|
||||
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Project", project.name, "total_purchase_cost"),
|
||||
existing_purchase_cost + 15000,
|
||||
@@ -858,14 +856,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
existing_purchase_cost + 15500,
|
||||
)
|
||||
|
||||
pi1.reload()
|
||||
pi1.cancel()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Project", project.name, "total_purchase_cost"),
|
||||
existing_purchase_cost + 15000,
|
||||
)
|
||||
|
||||
pi.reload()
|
||||
pi.cancel()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Project", project.name, "total_purchase_cost"), existing_purchase_cost
|
||||
@@ -2628,543 +2624,9 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
return_pi.submit()
|
||||
self.assertEqual(return_pi.docstatus, 1)
|
||||
|
||||
def test_internal_transfer_invoice_with_rejected_qty(self):
|
||||
"""An invoice that updates stock moves rejected material out of the in-transit warehouse and
|
||||
books it, like a receipt does."""
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
get_gl_entries,
|
||||
make_purchase_receipt,
|
||||
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"
|
||||
|
||||
from_warehouse = create_warehouse("_Test Invoice Transfer From", company=company)
|
||||
transit_warehouse = create_warehouse("_Test Invoice Transfer Transit", company=company)
|
||||
to_warehouse = create_warehouse("_Test Invoice Transfer To", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Invoice Transfer Rejected", company=company)
|
||||
|
||||
item_doc = create_item("Test Invoice Internal Transfer Item")
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
|
||||
)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
item_code=item_doc.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
warehouse=from_warehouse,
|
||||
update_stock=1,
|
||||
cost_center="Main - TCP1",
|
||||
debit_to="Debtors - TCP1",
|
||||
income_account="Sales - TCP1",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.items[0].target_warehouse = transit_warehouse
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_purchase_invoice(si.name)
|
||||
pi.update_stock = 1
|
||||
pi.items[0].warehouse = to_warehouse
|
||||
pi.items[0].qty = 7
|
||||
pi.items[0].rejected_qty = 3
|
||||
pi.items[0].received_qty = 10
|
||||
pi.items[0].rejected_warehouse = rejected_warehouse
|
||||
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
|
||||
pi.submit()
|
||||
|
||||
sl_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "actual_qty", "stock_value_difference"],
|
||||
)
|
||||
|
||||
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
|
||||
self.assertEqual(stock_qty[transit_warehouse], -10)
|
||||
self.assertEqual(stock_qty[to_warehouse], 7)
|
||||
self.assertEqual(stock_qty[rejected_warehouse], 3)
|
||||
|
||||
booked_value = {}
|
||||
for entry in get_gl_entries("Purchase Invoice", pi.name, skip_cancelled=True):
|
||||
booked_value.setdefault(entry.account, 0)
|
||||
booked_value[entry.account] += flt(entry.debit) - flt(entry.credit)
|
||||
|
||||
self.assertEqual(flt(sum(booked_value.values()), 2), 0)
|
||||
self.assertEqual(booked_value[get_inventory_account(company, transit_warehouse)], -1000)
|
||||
self.assertEqual(booked_value[get_inventory_account(company, to_warehouse)], 700)
|
||||
self.assertEqual(booked_value[get_inventory_account(company, rejected_warehouse)], 300)
|
||||
|
||||
def test_internal_transfer_invoice_with_rejected_batch_qty(self):
|
||||
"""Batch material rejected on a stock updating internal transfer invoice gets its own package."""
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
make_purchase_receipt,
|
||||
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"
|
||||
|
||||
from_warehouse = create_warehouse("_Test Batch Invoice Transfer From", company=company)
|
||||
transit_warehouse = create_warehouse("_Test Batch Invoice Transfer Transit", company=company)
|
||||
to_warehouse = create_warehouse("_Test Batch Invoice Transfer To", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Batch Invoice Transfer Rejected", company=company)
|
||||
|
||||
item = make_item(
|
||||
"Test Invoice Internal Transfer Batch Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TIITB-.####",
|
||||
},
|
||||
)
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item.name, company=company, warehouse=from_warehouse, qty=10, rate=100
|
||||
)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
warehouse=from_warehouse,
|
||||
update_stock=1,
|
||||
cost_center="Main - TCP1",
|
||||
debit_to="Debtors - TCP1",
|
||||
income_account="Sales - TCP1",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.items[0].target_warehouse = transit_warehouse
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_purchase_invoice(si.name)
|
||||
pi.update_stock = 1
|
||||
pi.items[0].warehouse = to_warehouse
|
||||
pi.items[0].qty = 7
|
||||
pi.items[0].rejected_qty = 3
|
||||
pi.items[0].received_qty = 10
|
||||
pi.items[0].rejected_warehouse = rejected_warehouse
|
||||
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
|
||||
pi.submit()
|
||||
|
||||
row = pi.items[0]
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty"), 7
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, "total_qty"),
|
||||
3,
|
||||
)
|
||||
|
||||
sl_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "actual_qty", "stock_value_difference"],
|
||||
)
|
||||
moved_qty = {d.warehouse: d.actual_qty for d in sl_entries}
|
||||
moved_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
|
||||
|
||||
self.assertEqual(moved_qty[transit_warehouse], -10)
|
||||
self.assertEqual(moved_qty[to_warehouse], 7)
|
||||
self.assertEqual(moved_qty[rejected_warehouse], 3)
|
||||
self.assertEqual(flt(moved_value[transit_warehouse]), -1000)
|
||||
self.assertEqual(flt(moved_value[to_warehouse]), 700)
|
||||
self.assertEqual(flt(moved_value[rejected_warehouse]), 300)
|
||||
|
||||
def test_internal_transfer_invoice_with_every_unit_rejected(self):
|
||||
"""An invoice may reject a whole row, and the in-transit warehouse is emptied all the same."""
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
make_purchase_receipt,
|
||||
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"
|
||||
|
||||
from_warehouse = create_warehouse("_Test Rejected Invoice From", company=company)
|
||||
transit_warehouse = create_warehouse("_Test Rejected Invoice Transit", company=company)
|
||||
to_warehouse = create_warehouse("_Test Rejected Invoice To", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Rejected Invoice Rejected", company=company)
|
||||
|
||||
item_doc = create_item("Test Fully Rejected Transfer Item")
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
|
||||
)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
item_code=item_doc.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
warehouse=from_warehouse,
|
||||
update_stock=1,
|
||||
cost_center="Main - TCP1",
|
||||
debit_to="Debtors - TCP1",
|
||||
income_account="Sales - TCP1",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.items[0].target_warehouse = transit_warehouse
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_purchase_invoice(si.name)
|
||||
pi.update_stock = 1
|
||||
pi.items[0].warehouse = to_warehouse
|
||||
pi.items[0].qty = 0
|
||||
pi.items[0].rejected_qty = 10
|
||||
pi.items[0].received_qty = 10
|
||||
pi.items[0].rejected_warehouse = rejected_warehouse
|
||||
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
|
||||
pi.submit()
|
||||
|
||||
sl_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "actual_qty", "stock_value_difference"],
|
||||
)
|
||||
moved_qty = {d.warehouse: d.actual_qty for d in sl_entries}
|
||||
moved_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
|
||||
|
||||
self.assertNotIn(to_warehouse, moved_qty)
|
||||
self.assertEqual(moved_qty[transit_warehouse], -10)
|
||||
self.assertEqual(moved_qty[rejected_warehouse], 10)
|
||||
self.assertEqual(flt(moved_value[transit_warehouse]), -1000)
|
||||
self.assertEqual(flt(moved_value[rejected_warehouse]), 1000)
|
||||
|
||||
def test_return_of_an_internal_transfer_invoice_that_rejected_everything(self):
|
||||
"""Material rejected in full goes back to the in-transit warehouse at the rate it came in
|
||||
with, and the entries say the same as the stock."""
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
make_purchase_receipt,
|
||||
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"
|
||||
|
||||
from_warehouse = create_warehouse("_Test Returned Transfer From", company=company)
|
||||
transit_warehouse = create_warehouse("_Test Returned Transfer Transit", company=company)
|
||||
to_warehouse = create_warehouse("_Test Returned Transfer To", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Returned Transfer Rejected", company=company)
|
||||
|
||||
item_doc = create_item("Test Returned Fully Rejected Transfer Item")
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
|
||||
)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
item_code=item_doc.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
warehouse=from_warehouse,
|
||||
update_stock=1,
|
||||
cost_center="Main - TCP1",
|
||||
debit_to="Debtors - TCP1",
|
||||
income_account="Sales - TCP1",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.items[0].target_warehouse = transit_warehouse
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_purchase_invoice(si.name)
|
||||
pi.update_stock = 1
|
||||
pi.items[0].warehouse = to_warehouse
|
||||
pi.items[0].qty = 0
|
||||
pi.items[0].rejected_qty = 10
|
||||
pi.items[0].received_qty = 10
|
||||
pi.items[0].rejected_warehouse = rejected_warehouse
|
||||
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
|
||||
pi.submit()
|
||||
|
||||
returned = make_return_doc("Purchase Invoice", pi.name)
|
||||
returned.update_stock = 1
|
||||
returned.submit()
|
||||
|
||||
moved = {
|
||||
d.warehouse: flt(d.stock_value_difference)
|
||||
for d in frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": returned.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "stock_value_difference"],
|
||||
)
|
||||
}
|
||||
self.assertEqual(moved[transit_warehouse], 1000)
|
||||
self.assertEqual(moved[rejected_warehouse], -1000)
|
||||
|
||||
booked = {}
|
||||
for entry in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": returned.name, "is_cancelled": 0},
|
||||
fields=["account", "debit", "credit"],
|
||||
):
|
||||
booked.setdefault(entry.account, 0)
|
||||
booked[entry.account] += flt(entry.debit) - flt(entry.credit)
|
||||
|
||||
self.assertEqual(flt(sum(booked.values()), 2), 0)
|
||||
self.assertEqual(booked[get_inventory_account(company, transit_warehouse)], 1000)
|
||||
self.assertEqual(booked[get_inventory_account(company, rejected_warehouse)], -1000)
|
||||
|
||||
def test_stock_updating_invoice_rejects_every_unit_of_a_row(self):
|
||||
"""A row of a stock updating invoice may be rejected in full."""
|
||||
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}).name
|
||||
rejected_warehouse = create_warehouse("_Test Fully Rejected Invoice Warehouse", company=company)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
company=company,
|
||||
item_code=item,
|
||||
warehouse="Stores - TCP1",
|
||||
qty=0,
|
||||
rejected_qty=10,
|
||||
received_qty=10,
|
||||
rate=100,
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
update_stock=1,
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
do_not_save=True,
|
||||
)
|
||||
pi.submit()
|
||||
|
||||
moved_qty = {
|
||||
d.warehouse: d.actual_qty
|
||||
for d in frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "actual_qty"],
|
||||
)
|
||||
}
|
||||
self.assertEqual(moved_qty, {rejected_warehouse: 10})
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 1,
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
},
|
||||
)
|
||||
def test_stock_updating_invoice_bills_the_rejected_quantity(self):
|
||||
"""With the rejected quantity billed and valued, the invoice pays for every unit received and
|
||||
the stock it moves matches 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 Billed Rejected Warehouse", company=company)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
self.assertEqual(pi.items[0].amount, 1000)
|
||||
self.assertEqual(pi.items[0].valuation_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], 400)
|
||||
|
||||
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), 1000)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 1,
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
},
|
||||
)
|
||||
def test_rejected_material_is_reposted_after_the_setting_changes(self):
|
||||
"""The entries an invoice books follow the stock it moved, so they can be built again once
|
||||
the settings have moved on."""
|
||||
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 Repost Rejected", company=company)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
company=company,
|
||||
item_code=item,
|
||||
warehouse="Stores - TCP1",
|
||||
qty=6,
|
||||
rejected_qty=4,
|
||||
received_qty=10,
|
||||
rate=100,
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
update_stock=1,
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0)
|
||||
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
|
||||
|
||||
rebuilt = pi.get_gl_entries()
|
||||
rejected_account = get_inventory_account(company, rejected_warehouse)
|
||||
|
||||
self.assertEqual(
|
||||
flt(sum(flt(entry.get("debit")) - flt(entry.get("credit")) for entry in rebuilt), 2), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
flt(
|
||||
sum(flt(entry.get("debit")) for entry in rebuilt if entry.get("account") == rejected_account)
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 1,
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
},
|
||||
)
|
||||
def test_return_without_a_reference_books_both_warehouses(self):
|
||||
"""A return that stands on its own gives back the rejected material too, and books it once."""
|
||||
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
|
||||
accepted_warehouse = create_warehouse("_Test Invoice Return Accepted", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Invoice Return Rejected", company=company)
|
||||
|
||||
def make_invoice(sign):
|
||||
return make_purchase_invoice(
|
||||
company=company,
|
||||
item_code=item,
|
||||
warehouse=accepted_warehouse,
|
||||
qty=6 * sign,
|
||||
rejected_qty=4 * sign,
|
||||
received_qty=10 * sign,
|
||||
rate=100,
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
update_stock=1,
|
||||
is_return=1 if sign < 0 else 0,
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
)
|
||||
|
||||
make_invoice(1)
|
||||
returned = make_invoice(-1)
|
||||
|
||||
booked = {}
|
||||
for entry in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": returned.name, "is_cancelled": 0},
|
||||
fields=["account", "debit", "credit"],
|
||||
):
|
||||
booked.setdefault(entry.account, 0)
|
||||
booked[entry.account] += flt(entry.debit) - flt(entry.credit)
|
||||
|
||||
self.assertEqual(flt(sum(booked.values()), 2), 0)
|
||||
self.assertEqual(booked[get_inventory_account(company, accepted_warehouse)], -600)
|
||||
self.assertEqual(booked[get_inventory_account(company, rejected_warehouse)], -400)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 1,
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
},
|
||||
)
|
||||
def test_discount_on_an_invoice_that_bills_the_rejected_quantity(self):
|
||||
"""A discount is spread over every unit the invoice pays for, not the accepted ones alone."""
|
||||
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 Discount Rejected", company=company)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
company=company,
|
||||
item_code=item,
|
||||
warehouse="Stores - TCP1",
|
||||
qty=6,
|
||||
rejected_qty=4,
|
||||
received_qty=10,
|
||||
rate=100,
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
update_stock=1,
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
do_not_save=True,
|
||||
)
|
||||
pi.apply_discount_on = "Net Total"
|
||||
pi.additional_discount_percentage = 10
|
||||
pi.submit()
|
||||
|
||||
self.assertEqual(pi.items[0].amount, 1000)
|
||||
self.assertEqual(pi.items[0].net_rate, 90)
|
||||
self.assertEqual(pi.grand_total, 900)
|
||||
self.assertEqual(frappe.db.get_value("Item", item, "last_purchase_rate"), 90)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 0,
|
||||
},
|
||||
)
|
||||
def test_rejected_material_is_not_valued_on_a_stock_updating_invoice(self):
|
||||
"""An invoice that does not bill the rejected quantity has nothing to pay for that material,
|
||||
so it carries no cost and the stock the invoice moves matches the entries it books."""
|
||||
"""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
|
||||
|
||||
@@ -3172,6 +2634,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
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,
|
||||
@@ -3187,8 +2654,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
rate=100,
|
||||
)
|
||||
|
||||
self.assertEqual(pi.items[0].amount, 600)
|
||||
|
||||
stock_value = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
|
||||
@@ -47,10 +47,6 @@
|
||||
"setting_field": "bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"settings_doctype": "Buying Settings"
|
||||
},
|
||||
{
|
||||
"setting_field": "set_valuation_rate_for_rejected_materials",
|
||||
"settings_doctype": "Buying Settings"
|
||||
},
|
||||
{
|
||||
"setting_field": "unlink_payment_on_cancellation_of_invoice",
|
||||
"settings_doctype": "Accounts Settings"
|
||||
|
||||
@@ -6,9 +6,8 @@ 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_user_permissions
|
||||
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
|
||||
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,
|
||||
@@ -160,7 +159,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, doctype)
|
||||
set_price_list(party_details, party, party_type, price_list, pos_profile)
|
||||
|
||||
tax_template = set_taxes(
|
||||
party.name,
|
||||
@@ -409,33 +408,13 @@ def get_default_price_list(party):
|
||||
return price_list
|
||||
|
||||
|
||||
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):
|
||||
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
|
||||
# price list
|
||||
permitted_price_lists = get_permitted_price_lists(doctype)
|
||||
price_list = get_permitted_documents("Price List")
|
||||
|
||||
# if there is only one permitted document based on user permissions, set it
|
||||
if len(permitted_price_lists) == 1:
|
||||
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
|
||||
if price_list and len(price_list) == 1:
|
||||
price_list = price_list[0]
|
||||
elif pos and party_type == "Customer":
|
||||
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
|
||||
|
||||
@@ -447,10 +426,6 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None,
|
||||
else:
|
||||
price_list = get_default_price_list(party) or given_price_list
|
||||
|
||||
# don't set a price list the user has no permission for, the transaction can't be saved with it
|
||||
if price_list and permitted_price_lists and price_list not in permitted_price_lists:
|
||||
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
|
||||
|
||||
if price_list and not is_price_list_enabled(price_list):
|
||||
price_list = None
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -35,159 +34,19 @@ class PartyTestCase(ERPNextTestSuite):
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
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):
|
||||
def create_price_list(self, enabled):
|
||||
price_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": selling,
|
||||
"buying": buying,
|
||||
"selling": 1,
|
||||
"enabled": enabled,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return price_list.name
|
||||
|
||||
def create_supplier(self, **values):
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier",
|
||||
"supplier_name": frappe.generate_hash(length=10),
|
||||
**values,
|
||||
}
|
||||
).insert(ignore_permissions=True, ignore_mandatory=True)
|
||||
|
||||
def create_customer(self, **values):
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Asset",
|
||||
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"]]",
|
||||
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"],[\"Asset\",\"company\",\"=\",\"eval:doc.company\"]]",
|
||||
"options": "Asset",
|
||||
"reqd": 1
|
||||
},
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "If checked, the rejected quantity is billed anywhere in the purchase cycle.",
|
||||
"description": "If checked, Rejected Quantity will be included while making Purchase Invoice from Purchase Receipt.",
|
||||
"fieldname": "bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"fieldtype": "Check",
|
||||
"label": "Bill for rejected quantity in Purchase Invoice"
|
||||
@@ -247,7 +247,7 @@
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"description": "If enabled, the system will generate an accounting entry for material rejected anywhere in the purchase cycle.",
|
||||
"description": "If enabled, the system will generate an accounting entry for materials rejected in the Purchase Receipt.",
|
||||
"fieldname": "set_valuation_rate_for_rejected_materials",
|
||||
"fieldtype": "Check",
|
||||
"label": "Set valuation rate for rejected Materials"
|
||||
|
||||
@@ -79,43 +79,12 @@ class BuyingSettings(Document):
|
||||
self.set_landed_cost_based_on_purchase_invoice_rate = 0
|
||||
|
||||
|
||||
def is_rejected_material_valued(voucher_type: str, voucher_detail_no: str | None = None) -> bool:
|
||||
"""Rejected material carries stock value only when something has paid for it.
|
||||
|
||||
Material of an internal transfer always has: its value was credited out of the in-transit
|
||||
warehouse. A Purchase Receipt books rejected material against Stock Received But Not Billed, so
|
||||
the supplier still owes an invoice for it, and Buying Settings decides. A stock updating Purchase
|
||||
Invoice pays for it only when it bills the received qty, which is what the settings ask for.
|
||||
"""
|
||||
if is_material_from_in_transit_warehouse(voucher_type, voucher_detail_no):
|
||||
return True
|
||||
|
||||
if not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
|
||||
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 voucher_type != "Purchase Invoice" or bool(
|
||||
frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice")
|
||||
)
|
||||
|
||||
|
||||
def is_material_from_in_transit_warehouse(voucher_type: str, voucher_detail_no: str | None) -> bool:
|
||||
if voucher_type not in ("Purchase Receipt", "Purchase Invoice") or not voucher_detail_no:
|
||||
return False
|
||||
|
||||
return bool(frappe.get_cached_value(voucher_type + " Item", voucher_detail_no, "from_warehouse"))
|
||||
|
||||
|
||||
def bills_rejected_quantity(doc) -> bool:
|
||||
"""An invoice that moves stock itself has no receipt to bill the rejected material for it, so it
|
||||
bills the received qty when the settings ask for the material to be valued.
|
||||
|
||||
An internal transfer bills nothing of the sort: its material is paid for by the warehouse it came
|
||||
out of.
|
||||
"""
|
||||
if doc.doctype != "Purchase Invoice" or not doc.get("update_stock"):
|
||||
return False
|
||||
|
||||
if doc.get("is_internal_supplier") and doc.get("represents_company") == doc.get("company"):
|
||||
return False
|
||||
|
||||
return is_rejected_material_valued(doc.doctype)
|
||||
return bool(frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"))
|
||||
|
||||
@@ -349,26 +349,12 @@ class AccountsController(TransactionBase):
|
||||
self.validate_company_in_accounting_dimension()
|
||||
|
||||
def validate_price_list(self):
|
||||
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_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
|
||||
price_list = self.get(price_list_field)
|
||||
if not price_list:
|
||||
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
|
||||
return
|
||||
|
||||
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.
|
||||
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
|
||||
if (
|
||||
self.get("is_return")
|
||||
and self.get("return_against")
|
||||
@@ -379,20 +365,9 @@ 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(
|
||||
message.format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Invalid Price List"),
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
)
|
||||
|
||||
def set_default_letter_head(self):
|
||||
@@ -749,15 +724,12 @@ 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 or (
|
||||
previous_price_list_currency and previous_price_list_currency != self.price_list_currency
|
||||
):
|
||||
elif not self.plc_conversion_rate:
|
||||
self.plc_conversion_rate = get_exchange_rate(
|
||||
self.price_list_currency, self.company_currency, transaction_date, args
|
||||
)
|
||||
@@ -1002,7 +974,7 @@ class AccountsController(TransactionBase):
|
||||
def validate_zero_qty_for_return_invoices_with_stock(self):
|
||||
rows = []
|
||||
for item in self.items:
|
||||
if not (flt(item.qty) or flt(item.get("rejected_qty"))):
|
||||
if not flt(item.qty):
|
||||
rows.append(item)
|
||||
if rows:
|
||||
frappe.throw(
|
||||
@@ -1011,18 +983,12 @@ class AccountsController(TransactionBase):
|
||||
).format(frappe.bold(comma_and(["#" + str(x.idx) for x in rows])))
|
||||
)
|
||||
|
||||
def is_stock_receipt(self) -> bool:
|
||||
"""Whether this document receives material into a warehouse."""
|
||||
return self.doctype == "Purchase Receipt" or (
|
||||
self.doctype == "Purchase Invoice" and self.update_stock
|
||||
)
|
||||
|
||||
def validate_qty_is_not_zero(self):
|
||||
if self.flags.allow_zero_qty:
|
||||
return
|
||||
|
||||
for item in self.items:
|
||||
if self.is_stock_receipt() and item.get("rejected_qty"):
|
||||
if self.doctype == "Purchase Receipt" and item.rejected_qty:
|
||||
continue
|
||||
|
||||
if not flt(item.qty):
|
||||
|
||||
@@ -15,20 +15,18 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
|
||||
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 (
|
||||
bills_rejected_quantity,
|
||||
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
|
||||
from erpnext.controllers.subcontracting_controller import SubcontractingController
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.get_item_details import (
|
||||
NOT_APPLICABLE_TAX,
|
||||
get_conversion_factor,
|
||||
get_item_defaults,
|
||||
)
|
||||
from erpnext.stock.utils import _get_incoming_rate, is_serial_no_wise_valuation_disabled
|
||||
from erpnext.stock.utils import _get_incoming_rate
|
||||
|
||||
|
||||
class QtyMismatchError(ValidationError):
|
||||
@@ -72,7 +70,6 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
if self.doctype in ("Purchase Receipt", "Purchase Invoice"):
|
||||
self.update_valuation_rate()
|
||||
self.sync_accepted_packages()
|
||||
self.set_serial_and_batch_bundle()
|
||||
|
||||
def onload(self):
|
||||
@@ -152,10 +149,13 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
for item in self.get("items"):
|
||||
if item.get(field) and not item.serial_and_batch_bundle and bundle_ids.get(item.get(field)):
|
||||
item.serial_and_batch_bundle = self.make_accepted_package(
|
||||
item, bundle_ids.get(item.get(field))
|
||||
item.serial_and_batch_bundle = self.make_package_for_transfer(
|
||||
bundle_ids.get(item.get(field)),
|
||||
item.from_warehouse,
|
||||
type_of_transaction="Outward",
|
||||
do_not_submit=True,
|
||||
qty=item.qty,
|
||||
)
|
||||
|
||||
elif (
|
||||
not self.is_new()
|
||||
and item.serial_and_batch_bundle
|
||||
@@ -178,138 +178,6 @@ class BuyingController(SubcontractingController):
|
||||
):
|
||||
frappe.set_value("Serial and Batch Entry", sabe[0], "qty", item.qty)
|
||||
|
||||
if item.get(field) and bundle_ids.get(item.get(field)):
|
||||
self.set_rejected_package(item, bundle_ids.get(item.get(field)))
|
||||
|
||||
def make_accepted_package(self, row, package) -> str:
|
||||
"""Package of the material the row accepts.
|
||||
|
||||
A row that rejects nothing keeps the package of the in-transit warehouse it came out of. A
|
||||
row that rejects material needs a package of the accepted warehouse instead, since that is
|
||||
the entry it belongs to; the material leaving the in-transit warehouse gets a package of its
|
||||
own when the receipt is submitted.
|
||||
"""
|
||||
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
|
||||
return self.make_package_for_transfer(
|
||||
package,
|
||||
row.from_warehouse,
|
||||
type_of_transaction="Outward",
|
||||
do_not_submit=True,
|
||||
qty=flt(row.stock_qty),
|
||||
)
|
||||
|
||||
if not flt(row.stock_qty):
|
||||
return ""
|
||||
|
||||
return self.make_package_for_transfer(
|
||||
package,
|
||||
row.warehouse,
|
||||
type_of_transaction="Inward",
|
||||
do_not_submit=True,
|
||||
qty=flt(row.stock_qty),
|
||||
exclude_serial_nos=self.get_rejected_serial_nos(row),
|
||||
)
|
||||
|
||||
def get_delivered_package(self, row) -> str | None:
|
||||
"""Package of the material the delivery note put in the in-transit warehouse."""
|
||||
field = "delivery_note_item" if self.doctype == "Purchase Receipt" else "sales_invoice_item"
|
||||
doctype = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item"
|
||||
if not row.get(field):
|
||||
return None
|
||||
|
||||
return frappe.db.get_value(doctype, row.get(field), "serial_and_batch_bundle")
|
||||
|
||||
def set_rejected_package(self, row, package) -> None:
|
||||
"""Package of the material the row rejects.
|
||||
|
||||
A receipt of an internal transfer builds no package for it on its own, so rejected material
|
||||
of a tracked item would have nothing to say where it came from.
|
||||
"""
|
||||
if not (self.is_internal_receipt() and flt(row.rejected_qty)) or self.is_return:
|
||||
return
|
||||
|
||||
if row.get("rejected_serial_and_batch_bundle") or not row.rejected_warehouse:
|
||||
return
|
||||
|
||||
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
|
||||
|
||||
row.rejected_serial_and_batch_bundle = self.make_package_for_transfer(
|
||||
package,
|
||||
row.rejected_warehouse,
|
||||
type_of_transaction="Inward",
|
||||
do_not_submit=True,
|
||||
qty=rejected_qty,
|
||||
exclude_serial_nos=self.get_accepted_serial_nos(row),
|
||||
)
|
||||
|
||||
frappe.db.set_value("Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, "is_rejected", 1)
|
||||
|
||||
def get_accepted_serial_nos(self, row) -> list:
|
||||
if not row.get("serial_and_batch_bundle"):
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": row.serial_and_batch_bundle, "serial_no": ("is", "set")},
|
||||
pluck="serial_no",
|
||||
)
|
||||
|
||||
def sync_accepted_packages(self) -> None:
|
||||
"""Keep the package of a row in the shape its own entry needs.
|
||||
|
||||
A row that rejects material carries the package of its accepted warehouse; a row that
|
||||
rejects nothing carries the package of the in-transit warehouse it came out of. Editing the
|
||||
split moves the package from one to the other.
|
||||
"""
|
||||
if not self.is_internal_receipt() or self.is_return:
|
||||
return
|
||||
|
||||
for row in self.get("items"):
|
||||
package = row.get("serial_and_batch_bundle")
|
||||
if not package:
|
||||
continue
|
||||
|
||||
details = frappe.db.get_value(
|
||||
"Serial and Batch Bundle",
|
||||
package,
|
||||
["warehouse", "type_of_transaction", "docstatus"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not details or details.docstatus != 0:
|
||||
continue
|
||||
|
||||
if flt(row.rejected_qty):
|
||||
wanted = (row.warehouse, "Inward")
|
||||
else:
|
||||
wanted = (row.from_warehouse, "Outward")
|
||||
|
||||
if (details.warehouse, details.type_of_transaction) == wanted:
|
||||
continue
|
||||
|
||||
row.serial_and_batch_bundle = self.make_accepted_package(
|
||||
row, self.get_delivered_package(row) or package
|
||||
)
|
||||
frappe.delete_doc("Serial and Batch Bundle", package, force=True, ignore_permissions=True)
|
||||
|
||||
def get_internal_transfer_qty(self, row) -> float:
|
||||
if flt(row.qty) or not self.is_internal_receipt():
|
||||
return flt(row.qty)
|
||||
|
||||
return flt(row.rejected_qty)
|
||||
|
||||
def get_rejected_serial_nos(self, row) -> list:
|
||||
if not flt(row.get("rejected_qty")):
|
||||
return []
|
||||
|
||||
if row.get("rejected_serial_and_batch_bundle"):
|
||||
return frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": row.rejected_serial_and_batch_bundle, "serial_no": ("is", "set")},
|
||||
pluck="serial_no",
|
||||
)
|
||||
|
||||
return get_serial_nos(row.get("rejected_serial_no"))
|
||||
|
||||
def set_rate_for_standalone_debit_note(self):
|
||||
if self.get("is_return") and self.get("update_stock") and not self.return_against:
|
||||
for row in self.items:
|
||||
@@ -601,7 +469,7 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
net_rate = item.base_net_amount
|
||||
if item.sales_incoming_rate: # for internal transfer
|
||||
net_rate = self.get_internal_transfer_qty(item) * item.sales_incoming_rate
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
|
||||
if (
|
||||
not net_rate
|
||||
@@ -612,7 +480,7 @@ class BuyingController(SubcontractingController):
|
||||
):
|
||||
net_rate = item.rejected_qty * item.net_rate
|
||||
|
||||
qty_in_stock_uom = flt(self.get_valued_qty(item) * item.conversion_factor)
|
||||
qty_in_stock_uom = flt(item.qty * item.conversion_factor)
|
||||
if not qty_in_stock_uom and item.get("rejected_qty"):
|
||||
qty_in_stock_uom = flt(item.rejected_qty * item.conversion_factor)
|
||||
|
||||
@@ -627,14 +495,6 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
update_regional_item_valuation_rate(self)
|
||||
|
||||
def get_valued_qty(self, row):
|
||||
"""Quantity the net amount of the row was billed for, which is what its valuation spreads
|
||||
over."""
|
||||
if not flt(row.get("rejected_qty")) or not bills_rejected_quantity(self):
|
||||
return flt(row.qty)
|
||||
|
||||
return flt(row.qty) + flt(row.rejected_qty)
|
||||
|
||||
def get_tax_details(self):
|
||||
tax_accounts = []
|
||||
total_valuation_amount = 0.0
|
||||
@@ -760,11 +620,7 @@ class BuyingController(SubcontractingController):
|
||||
return
|
||||
|
||||
if cint(self.get("is_return")):
|
||||
# Material of a transfer goes back at the rate it came in with. Anything else is
|
||||
# valued from the original item cost by its valuation method.
|
||||
if self.is_internal_transfer():
|
||||
self.set_sales_incoming_rate_for_internal_transfer()
|
||||
|
||||
# Get outgoing rate based on original item cost based on valuation method
|
||||
return
|
||||
|
||||
if not self.is_internal_transfer():
|
||||
@@ -805,13 +661,8 @@ class BuyingController(SubcontractingController):
|
||||
}
|
||||
|
||||
ref_doctype = ref_doctype_map.get(self.doctype)
|
||||
returned_field = frappe.scrub(self.doctype) + "_item"
|
||||
for d in self.get("items"):
|
||||
if self.get("is_return") and d.get(returned_field):
|
||||
d.sales_incoming_rate = flt(
|
||||
frappe.db.get_value(self.doctype + " Item", d.get(returned_field), "sales_incoming_rate")
|
||||
)
|
||||
elif not d.get(frappe.scrub(ref_doctype)):
|
||||
if not d.get(frappe.scrub(ref_doctype)):
|
||||
posting_time = self.get("posting_time")
|
||||
if not posting_time:
|
||||
posting_time = nowtime()
|
||||
@@ -906,95 +757,6 @@ class BuyingController(SubcontractingController):
|
||||
)
|
||||
)
|
||||
|
||||
def is_internal_receipt(self) -> bool:
|
||||
return self.is_internal_transfer() and self.is_stock_receipt()
|
||||
|
||||
def get_source_warehouse_qty(self, row, accepted_qty):
|
||||
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
|
||||
return accepted_qty
|
||||
|
||||
if row.get("serial_and_batch_bundle") and not row.get("rejected_serial_and_batch_bundle"):
|
||||
return accepted_qty
|
||||
|
||||
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
|
||||
|
||||
return flt(accepted_qty + rejected_qty, row.precision("stock_qty"))
|
||||
|
||||
def get_accepted_warehouse_package(self, row, type_of_transaction, via_landed_cost_voucher):
|
||||
"""Package for the entry into the accepted warehouse, which is the package of the row itself
|
||||
when the row rejects material."""
|
||||
if flt(row.rejected_qty) and self.is_internal_receipt() and not self.is_return:
|
||||
return row.serial_and_batch_bundle
|
||||
|
||||
if self.is_internal_transfer() and not self.is_return and self.docstatus != 2:
|
||||
return self.get_package_for_target_warehouse(
|
||||
row,
|
||||
type_of_transaction=type_of_transaction,
|
||||
via_landed_cost_voucher=via_landed_cost_voucher,
|
||||
)
|
||||
|
||||
return row.serial_and_batch_bundle
|
||||
|
||||
def get_submitted_package(self, row, warehouse):
|
||||
return frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": row.name, "warehouse": warehouse, "is_cancelled": 0},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
|
||||
def get_source_warehouse_reversal_package(self, row, package):
|
||||
if not (self.is_internal_transfer() and self.is_return):
|
||||
return package
|
||||
|
||||
if existing_package := self.get_package_of_source_warehouse(row):
|
||||
return existing_package
|
||||
|
||||
if not row.get("rejected_serial_and_batch_bundle"):
|
||||
return self.get_package_for_target_warehouse(row, row.from_warehouse, "Inward")
|
||||
|
||||
return self.get_returned_source_package(row)
|
||||
|
||||
def get_source_warehouse_package(self, row, package):
|
||||
if not (row.get("rejected_serial_and_batch_bundle") and self.is_internal_receipt()):
|
||||
return package
|
||||
|
||||
if existing_package := self.get_package_of_source_warehouse(row):
|
||||
return existing_package
|
||||
|
||||
if not package:
|
||||
return self.make_package_for_transfer(
|
||||
row.rejected_serial_and_batch_bundle, row.from_warehouse, type_of_transaction="Outward"
|
||||
)
|
||||
|
||||
return self.make_package_for_transfer(
|
||||
package,
|
||||
row.from_warehouse,
|
||||
type_of_transaction="Outward",
|
||||
include_bundle=row.rejected_serial_and_batch_bundle,
|
||||
)
|
||||
|
||||
def get_package_of_source_warehouse(self, row) -> str | None:
|
||||
return frappe.db.get_value(
|
||||
"Serial and Batch Bundle",
|
||||
{
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_no": self.name,
|
||||
"voucher_detail_no": row.name,
|
||||
"warehouse": row.from_warehouse,
|
||||
"docstatus": 1,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
|
||||
def get_returned_source_package(self, row):
|
||||
return self.make_package_for_transfer(
|
||||
row.serial_and_batch_bundle,
|
||||
row.from_warehouse,
|
||||
type_of_transaction="Inward",
|
||||
include_bundle=row.rejected_serial_and_batch_bundle,
|
||||
)
|
||||
|
||||
def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
|
||||
self.update_ordered_and_reserved_qty()
|
||||
|
||||
@@ -1005,114 +767,114 @@ class BuyingController(SubcontractingController):
|
||||
if d.item_code not in stock_items:
|
||||
continue
|
||||
|
||||
source_reversal_sle = None
|
||||
if d.warehouse:
|
||||
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
|
||||
|
||||
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
|
||||
source_qty = self.get_source_warehouse_qty(d, pr_qty)
|
||||
if pr_qty:
|
||||
if d.from_warehouse and (
|
||||
(not cint(self.is_return) and self.docstatus == 1)
|
||||
or (cint(self.is_return) and self.docstatus == 2)
|
||||
):
|
||||
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
|
||||
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
|
||||
serial_and_batch_bundle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
|
||||
if source_qty and (d.warehouse or not pr_qty):
|
||||
if d.from_warehouse and (
|
||||
(not cint(self.is_return) and self.docstatus == 1)
|
||||
or (cint(self.is_return) and self.docstatus == 2)
|
||||
):
|
||||
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
|
||||
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
|
||||
serial_and_batch_bundle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
|
||||
"serial_and_batch_bundle",
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": -1 * pr_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"outgoing_rate": d.rate,
|
||||
"recalculate_rate": 1,
|
||||
"dependant_sle_voucher_detail_no": d.name,
|
||||
"serial_and_batch_bundle": serial_and_batch_bundle,
|
||||
},
|
||||
)
|
||||
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
sl_entries.append(from_warehouse_sle)
|
||||
|
||||
type_of_transaction = "Inward"
|
||||
if self.docstatus == 2:
|
||||
type_of_transaction = "Outward"
|
||||
|
||||
sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": -1 * source_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"outgoing_rate": d.rate,
|
||||
"recalculate_rate": 1,
|
||||
"dependant_sle_voucher_detail_no": d.name,
|
||||
"serial_and_batch_bundle": self.get_source_warehouse_package(
|
||||
d, serial_and_batch_bundle
|
||||
"actual_qty": flt(pr_qty),
|
||||
"serial_and_batch_bundle": (
|
||||
d.serial_and_batch_bundle
|
||||
if not self.is_internal_transfer()
|
||||
or self.is_return
|
||||
or (self.is_internal_transfer() and self.docstatus == 2)
|
||||
else self.get_package_for_target_warehouse(
|
||||
d,
|
||||
type_of_transaction=type_of_transaction,
|
||||
via_landed_cost_voucher=via_landed_cost_voucher,
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
sl_entries.append(from_warehouse_sle)
|
||||
|
||||
type_of_transaction = "Inward"
|
||||
if self.docstatus == 2:
|
||||
type_of_transaction = "Outward"
|
||||
|
||||
sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": flt(pr_qty),
|
||||
"serial_and_batch_bundle": self.get_accepted_warehouse_package(
|
||||
d, type_of_transaction, via_landed_cost_voucher
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if self.is_return:
|
||||
outgoing_rate = 0.0
|
||||
if not is_serial_no_wise_valuation_disabled(d.item_code):
|
||||
if self.is_return:
|
||||
outgoing_rate = get_rate_for_return(
|
||||
self.doctype, self.name, d.item_code, self.return_against, item_row=d
|
||||
)
|
||||
|
||||
sle.update(
|
||||
{
|
||||
"outgoing_rate": outgoing_rate,
|
||||
"recalculate_rate": 1,
|
||||
"serial_and_batch_bundle": d.serial_and_batch_bundle,
|
||||
}
|
||||
)
|
||||
if d.from_warehouse:
|
||||
sle.dependant_sle_voucher_detail_no = d.name
|
||||
else:
|
||||
sle.update(
|
||||
{
|
||||
"incoming_rate": d.valuation_rate,
|
||||
"recalculate_rate": 1
|
||||
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
|
||||
else 0,
|
||||
}
|
||||
)
|
||||
sl_entries.append(sle)
|
||||
|
||||
if d.from_warehouse and (
|
||||
(not cint(self.is_return) and self.docstatus == 2)
|
||||
or (cint(self.is_return) and self.docstatus == 1)
|
||||
):
|
||||
serial_and_batch_bundle = None
|
||||
if self.is_internal_transfer() and self.docstatus == 2:
|
||||
reversed_warehouse = (
|
||||
d.from_warehouse if d.get("rejected_serial_and_batch_bundle") else d.warehouse
|
||||
sle.update(
|
||||
{
|
||||
"outgoing_rate": outgoing_rate,
|
||||
"recalculate_rate": 1,
|
||||
"serial_and_batch_bundle": d.serial_and_batch_bundle,
|
||||
}
|
||||
)
|
||||
serial_and_batch_bundle = self.get_submitted_package(d, reversed_warehouse)
|
||||
if d.from_warehouse:
|
||||
sle.dependant_sle_voucher_detail_no = d.name
|
||||
else:
|
||||
sle.update(
|
||||
{
|
||||
"incoming_rate": d.valuation_rate,
|
||||
"recalculate_rate": 1
|
||||
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
|
||||
else 0,
|
||||
}
|
||||
)
|
||||
sl_entries.append(sle)
|
||||
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": -1 * source_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"recalculate_rate": 1,
|
||||
"serial_and_batch_bundle": self.get_source_warehouse_reversal_package(
|
||||
d, serial_and_batch_bundle
|
||||
),
|
||||
},
|
||||
)
|
||||
if d.from_warehouse and (
|
||||
(not cint(self.is_return) and self.docstatus == 2)
|
||||
or (cint(self.is_return) and self.docstatus == 1)
|
||||
):
|
||||
serial_and_batch_bundle = None
|
||||
if self.is_internal_transfer() and self.docstatus == 2:
|
||||
serial_and_batch_bundle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": d.name, "warehouse": d.warehouse},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
|
||||
if self.is_internal_transfer() and self.is_return:
|
||||
from_warehouse_sle.incoming_rate = get_rate_for_return(
|
||||
self.doctype, self.name, d.item_code, self.return_against, item_row=d
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": -1 * pr_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"recalculate_rate": 1,
|
||||
"serial_and_batch_bundle": (
|
||||
self.get_package_for_target_warehouse(d, d.from_warehouse, "Inward")
|
||||
if self.is_internal_transfer() and self.is_return
|
||||
else serial_and_batch_bundle
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
source_reversal_sle = from_warehouse_sle
|
||||
sl_entries.append(from_warehouse_sle)
|
||||
|
||||
if flt(d.rejected_qty) != 0:
|
||||
valuation_rate_for_rejected_item = 0.0
|
||||
if is_rejected_material_valued(self.doctype, d.name):
|
||||
if is_rejected_material_valued(self.doctype):
|
||||
valuation_rate_for_rejected_item = d.valuation_rate
|
||||
|
||||
sl_entries.append(
|
||||
@@ -1130,9 +892,6 @@ class BuyingController(SubcontractingController):
|
||||
)
|
||||
)
|
||||
|
||||
if source_reversal_sle:
|
||||
sl_entries.append(source_reversal_sle)
|
||||
|
||||
self.make_sl_entries(
|
||||
sl_entries,
|
||||
allow_negative_stock=allow_negative_stock,
|
||||
|
||||
@@ -170,7 +170,7 @@ def validate_returned_items(doc):
|
||||
"Delivery Note",
|
||||
"POS Invoice",
|
||||
):
|
||||
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0 or flt(d.get("rejected_qty")) < 0:
|
||||
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
|
||||
items_returned = True
|
||||
else:
|
||||
items_returned = True
|
||||
@@ -736,7 +736,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
if return_against_rejected_qty:
|
||||
return doc.rejected_qty
|
||||
|
||||
return doc.qty or doc.get("rejected_qty")
|
||||
return doc.qty
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
doctype,
|
||||
@@ -885,13 +885,8 @@ def get_filters(
|
||||
if reference_voucher_detail_no:
|
||||
warehouses = get_warehouses_for_return(voucher_type, reference_voucher_detail_no)
|
||||
|
||||
# A row that accepted nothing goes back at the rate the rejected warehouse received it at.
|
||||
warehouse_field = "warehouse"
|
||||
if not flt(item_row.get("qty")) and flt(item_row.get("rejected_qty")):
|
||||
warehouse_field = "rejected_warehouse"
|
||||
|
||||
if item_row.get(warehouse_field) and item_row.get(warehouse_field) in warehouses:
|
||||
filters["warehouse"] = item_row.get(warehouse_field)
|
||||
if item_row.get("warehouse") and item_row.get("warehouse") in warehouses:
|
||||
filters["warehouse"] = item_row.get("warehouse")
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
@@ -260,25 +260,12 @@ class StockController(AccountsController):
|
||||
return SerialBatchBundleService(self).set_serial_and_batch_bundle(table_name, ignore_validate)
|
||||
|
||||
def make_package_for_transfer(
|
||||
self,
|
||||
serial_and_batch_bundle,
|
||||
warehouse,
|
||||
type_of_transaction=None,
|
||||
do_not_submit=None,
|
||||
qty=0,
|
||||
include_bundle=None,
|
||||
exclude_serial_nos=None,
|
||||
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
|
||||
):
|
||||
from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService
|
||||
|
||||
return SerialBatchBundleService(self).make_package_for_transfer(
|
||||
serial_and_batch_bundle,
|
||||
warehouse,
|
||||
type_of_transaction,
|
||||
do_not_submit,
|
||||
qty,
|
||||
include_bundle,
|
||||
exclude_serial_nos,
|
||||
serial_and_batch_bundle, warehouse, type_of_transaction, do_not_submit, qty
|
||||
)
|
||||
|
||||
def get_sl_entries(self, d, args):
|
||||
@@ -954,20 +941,10 @@ def make_bundle_for_material_transfer(**kwargs):
|
||||
bundle_doc.voucher_no = "" if kwargs.is_new or kwargs.docstatus == 2 else kwargs.voucher_no
|
||||
bundle_doc.is_cancelled = 0
|
||||
|
||||
if kwargs.include_bundle:
|
||||
for entry in frappe.get_doc("Serial and Batch Bundle", kwargs.include_bundle).entries:
|
||||
bundle_doc.append("entries", entry.as_dict(no_default_fields=True))
|
||||
|
||||
if kwargs.exclude_serial_nos:
|
||||
keep = [row for row in bundle_doc.entries if row.serial_no not in set(kwargs.exclude_serial_nos)]
|
||||
bundle_doc.entries = keep
|
||||
for idx, row in enumerate(keep, start=1):
|
||||
row.idx = idx
|
||||
|
||||
qty = 0
|
||||
if (
|
||||
len(bundle_doc.entries) == 1
|
||||
and abs(flt(kwargs.qty)) < abs(flt(bundle_doc.total_qty))
|
||||
and flt(kwargs.qty) < flt(bundle_doc.total_qty)
|
||||
and not bundle_doc.has_serial_no
|
||||
):
|
||||
qty = kwargs.qty
|
||||
|
||||
@@ -13,7 +13,6 @@ from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
|
||||
from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules
|
||||
from erpnext.buying.doctype.buying_settings.buying_settings import bills_rejected_quantity
|
||||
from erpnext.controllers.accounts_controller import (
|
||||
validate_conversion_rate,
|
||||
validate_inclusive_tax,
|
||||
@@ -242,19 +241,13 @@ class calculate_taxes_and_totals:
|
||||
elif not item.qty and self.doc.get("is_debit_note"):
|
||||
item.amount = flt(item.rate, item.precision("amount"))
|
||||
else:
|
||||
item.amount = flt(item.rate * self.get_billed_qty(item), item.precision("amount"))
|
||||
item.amount = flt(item.rate * item.qty, item.precision("amount"))
|
||||
item.net_amount = item.amount
|
||||
self._set_in_company_currency(
|
||||
item, ["price_list_rate", "rate_with_margin", "rate", "net_rate", "amount", "net_amount"]
|
||||
)
|
||||
item.item_tax_amount = 0.0
|
||||
|
||||
def get_billed_qty(self, item):
|
||||
if not flt(item.get("rejected_qty")) or not bills_rejected_quantity(self.doc):
|
||||
return flt(item.qty)
|
||||
|
||||
return flt(item.qty) + flt(item.rejected_qty)
|
||||
|
||||
def _set_in_company_currency(self, doc, fields):
|
||||
"""set values in base currency"""
|
||||
for f in fields:
|
||||
@@ -346,7 +339,7 @@ class calculate_taxes_and_totals:
|
||||
|
||||
item._unrounded_net_amount = amount / (1 + total_tax_slope)
|
||||
item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
|
||||
item.net_rate = flt(item.net_amount / self.get_billed_qty(item), item.precision("net_rate"))
|
||||
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
|
||||
item.discount_percentage = flt(
|
||||
item.discount_percentage, item.precision("discount_percentage")
|
||||
)
|
||||
@@ -952,9 +945,8 @@ class calculate_taxes_and_totals:
|
||||
)
|
||||
net_total += rounding_difference
|
||||
|
||||
billed_qty = self.get_billed_qty(item)
|
||||
item.net_rate = (
|
||||
flt(item.net_amount / billed_qty, item.precision("net_rate")) if billed_qty else 0
|
||||
flt(item.net_amount / item.qty, item.precision("net_rate")) if item.qty else 0
|
||||
)
|
||||
|
||||
self._set_in_company_currency(item, ["net_rate", "net_amount"])
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,93 +0,0 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPriceListValidation(ERPNextTestSuite):
|
||||
def create_price_list(self, selling=0, buying=0, enabled=1):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": selling,
|
||||
"buying": buying,
|
||||
"enabled": enabled,
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
def test_selling_transaction_should_reject_a_buying_price_list(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = self.create_price_list(buying=1)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "selling transaction"):
|
||||
invoice.save()
|
||||
|
||||
def test_buying_transaction_should_reject_a_selling_price_list(self):
|
||||
invoice = make_purchase_invoice(do_not_save=1)
|
||||
invoice.buying_price_list = self.create_price_list(selling=1)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "buying transaction"):
|
||||
invoice.save()
|
||||
|
||||
def test_a_price_list_for_both_sides_should_be_accepted(self):
|
||||
price_list = self.create_price_list(selling=1, buying=1)
|
||||
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = price_list
|
||||
invoice.save()
|
||||
|
||||
self.assertEqual(invoice.selling_price_list, price_list)
|
||||
|
||||
def test_a_missing_price_list_should_report_rather_than_crash(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = frappe.generate_hash(length=10)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
invoice.validate_price_list()
|
||||
|
||||
def test_internal_transfer_should_keep_the_outward_price_list(self):
|
||||
"""The inward document of an internal transfer takes the price list of the outward one, which
|
||||
is flagged for the opposite side."""
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
company = "_Test Company with perpetual inventory"
|
||||
selling_only = self.create_price_list(selling=1)
|
||||
|
||||
delivery_note = create_delivery_note(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
cost_center="Main - TCP1",
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
warehouse="Stores - TCP1",
|
||||
target_warehouse=create_warehouse("_Test Transit For Price List", company=company),
|
||||
do_not_submit=1,
|
||||
)
|
||||
delivery_note.selling_price_list = selling_only
|
||||
delivery_note.save()
|
||||
delivery_note.submit()
|
||||
|
||||
receipt = make_inter_company_purchase_receipt(delivery_note.name)
|
||||
receipt.items[0].warehouse = "Stores - TCP1"
|
||||
receipt.save()
|
||||
|
||||
self.assertEqual(receipt.buying_price_list, selling_only)
|
||||
|
||||
def test_disabled_price_list_should_still_report_as_disabled(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = self.create_price_list(selling=1, enabled=0)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
invoice.save()
|
||||
@@ -285,7 +285,7 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
|
||||
}
|
||||
|
||||
if (this.frm.is_new() && this.frm.doc.opportunity_type === undefined) {
|
||||
this.frm.doc.opportunity_type = "Sales";
|
||||
this.frm.doc.opportunity_type = __("Sales");
|
||||
}
|
||||
this.setup_queries();
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
|
||||
def set_opportunity_type(self):
|
||||
if self.is_new() and not self.opportunity_type:
|
||||
self.opportunity_type = "Sales"
|
||||
self.opportunity_type = _("Sales")
|
||||
|
||||
def set_exchange_rate(self):
|
||||
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
|
||||
@@ -523,4 +523,3 @@ erpnext.patches.v16_0.set_supplier_quotation_order_status
|
||||
erpnext.patches.v16_0.recalculate_holiday_list_totals
|
||||
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
|
||||
erpnext.patches.v16_0.rename_component_cost_valuation_type
|
||||
erpnext.patches.v16_0.enable_serial_no_wise_valuation
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
item = frappe.qb.DocType("Item")
|
||||
|
||||
(
|
||||
frappe.qb.update(item)
|
||||
.set(item.use_serial_no_wise_valuation, 1)
|
||||
.where((item.has_serial_no == 1) & (item.use_serial_no_wise_valuation == 0))
|
||||
).run()
|
||||
@@ -157,24 +157,6 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
.filter((fieldname) => !do_not_round_fields.includes(fieldname));
|
||||
}
|
||||
|
||||
get_billed_qty(item) {
|
||||
const settings = frappe.boot.sysdefaults || {};
|
||||
const is_internal_transfer =
|
||||
this.frm.doc.is_internal_supplier && this.frm.doc.represents_company === this.frm.doc.company;
|
||||
const bills_rejected_quantity =
|
||||
this.frm.doc.doctype === "Purchase Invoice" &&
|
||||
this.frm.doc.update_stock &&
|
||||
!is_internal_transfer &&
|
||||
cint(settings.set_valuation_rate_for_rejected_materials) &&
|
||||
cint(settings.bill_for_rejected_quantity_in_purchase_invoice);
|
||||
|
||||
if (!flt(item.rejected_qty) || !bills_rejected_quantity) {
|
||||
return flt(item.qty);
|
||||
}
|
||||
|
||||
return flt(item.qty) + flt(item.rejected_qty);
|
||||
}
|
||||
|
||||
calculate_item_values() {
|
||||
var me = this;
|
||||
if (!this.discount_amount_applied) {
|
||||
@@ -185,10 +167,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;
|
||||
|
||||
if (!(me.frm.doc.is_return || me.frm.doc.is_debit_note)) {
|
||||
item.net_amount = item.amount = flt(
|
||||
item.rate * me.get_billed_qty(item),
|
||||
precision("amount", item)
|
||||
);
|
||||
item.net_amount = item.amount = flt(item.rate * item.qty, precision("amount", item));
|
||||
} else {
|
||||
// allow for '0' qty on Credit/Debit notes
|
||||
let qty = flt(item.qty);
|
||||
|
||||
@@ -252,6 +252,7 @@ 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
|
||||
@@ -693,6 +694,41 @@ class SalesOrder(SellingController):
|
||||
),
|
||||
)
|
||||
|
||||
def validate_serial_no_based_delivery(self):
|
||||
reserved_items = []
|
||||
normal_items = []
|
||||
for item in self.items:
|
||||
if item.ensure_delivery_based_on_produced_serial_no:
|
||||
if item.item_code in normal_items:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No."
|
||||
).format(item.item_code)
|
||||
)
|
||||
if item.item_code not in reserved_items:
|
||||
if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Item {0} has no Serial No. Only serialized items can have delivery based on Serial No"
|
||||
).format(item.item_code)
|
||||
)
|
||||
if not frappe.db.exists("BOM", {"item": item.item_code, "is_active": 1}):
|
||||
frappe.throw(
|
||||
_(
|
||||
"No active BOM found for item {0}. Delivery by Serial No cannot be ensured"
|
||||
).format(item.item_code)
|
||||
)
|
||||
reserved_items.append(item.item_code)
|
||||
else:
|
||||
normal_items.append(item.item_code)
|
||||
|
||||
if not item.ensure_delivery_based_on_produced_serial_no and item.item_code in reserved_items:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No."
|
||||
).format(item.item_code)
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def has_unreserved_stock(self, table_name: str = "items") -> dict:
|
||||
"""Returns unreserved qty per item if there is any unreserved item in the Sales Order."""
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"customer_item_code",
|
||||
"ensure_delivery_based_on_produced_serial_no",
|
||||
"is_stock_item",
|
||||
"reserve_stock",
|
||||
"col_break1",
|
||||
@@ -164,6 +165,12 @@
|
||||
"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"
|
||||
@@ -1070,7 +1077,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-09-21 10:12:00.000000",
|
||||
"modified": "2026-08-27 11:55:37.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
|
||||
@@ -42,6 +42,7 @@ class SalesOrderItem(Document):
|
||||
discount_amount: DF.Currency
|
||||
discount_percentage: DF.Percent
|
||||
distributed_discount_amount: DF.Currency
|
||||
ensure_delivery_based_on_produced_serial_no: DF.Check
|
||||
fg_item: DF.Link | None
|
||||
fg_item_qty: DF.Float
|
||||
grant_commission: DF.Check
|
||||
|
||||
@@ -89,30 +89,6 @@ frappe.query_reports["Sales Order Analysis"] = {
|
||||
label: __("Group by Sales Order"),
|
||||
fieldtype: "Check",
|
||||
default: 0,
|
||||
on_change: (report) => {
|
||||
if (report.get_filter_value("group_by_so") && report.get_filter_value("group_by_item")) {
|
||||
report.set_filter_value("group_by_item", 0);
|
||||
return;
|
||||
}
|
||||
if (!report._no_refresh) {
|
||||
report.refresh(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "group_by_item",
|
||||
label: __("Group by Item"),
|
||||
fieldtype: "Check",
|
||||
default: 0,
|
||||
on_change: (report) => {
|
||||
if (report.get_filter_value("group_by_so") && report.get_filter_value("group_by_item")) {
|
||||
report.set_filter_value("group_by_so", 0);
|
||||
return;
|
||||
}
|
||||
if (!report._no_refresh) {
|
||||
report.refresh(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -8,23 +8,18 @@ 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, nowdate
|
||||
|
||||
import erpnext
|
||||
from frappe.utils import date_diff, flt, getdate, nowdate
|
||||
|
||||
|
||||
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 = {} if filters.get("group_by_item") else get_so_elapsed_time(data)
|
||||
so_elapsed_time = get_so_elapsed_time(data)
|
||||
|
||||
if not data:
|
||||
return [], [], None, []
|
||||
@@ -35,9 +30,6 @@ 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:
|
||||
@@ -45,9 +37,6 @@ 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")
|
||||
@@ -76,7 +65,6 @@ 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,
|
||||
@@ -93,7 +81,6 @@ 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)
|
||||
@@ -101,6 +88,8 @@ 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"):
|
||||
@@ -162,86 +151,71 @@ 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 = group_by_sales_order(data)
|
||||
elif filters.get("group_by_item"):
|
||||
data = group_by_item(data)
|
||||
data = []
|
||||
for so in sales_order_map:
|
||||
data.append(sales_order_map[so])
|
||||
return data, chart_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")]
|
||||
|
||||
@@ -253,34 +227,7 @@ def prepare_chart_data(pending, completed):
|
||||
|
||||
|
||||
def get_columns(filters):
|
||||
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 [
|
||||
columns = [
|
||||
{"label": _("Date"), "fieldname": "date", "fieldtype": "Date", "width": 90},
|
||||
{
|
||||
"label": _("Sales Order"),
|
||||
@@ -299,139 +246,117 @@ def get_sales_order_columns():
|
||||
},
|
||||
]
|
||||
|
||||
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}
|
||||
)
|
||||
|
||||
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 [
|
||||
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(
|
||||
{
|
||||
"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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
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",
|
||||
"label": _("Company"),
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"options": "Company",
|
||||
"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,
|
||||
}
|
||||
return columns
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
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 (
|
||||
AGGREGATED_FIELDS,
|
||||
execute,
|
||||
group_by_item,
|
||||
)
|
||||
from erpnext.selling.report.sales_order_analysis.sales_order_analysis import execute
|
||||
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, qty=10, uom=None):
|
||||
def create_sales_order(self, transaction_date, do_not_save=False, do_not_submit=False):
|
||||
item = create_item(item_code="_Test Excavator", is_stock_item=0)
|
||||
so = make_sales_order(
|
||||
transaction_date=transaction_date,
|
||||
item=item.item_code,
|
||||
qty=qty,
|
||||
uom=uom,
|
||||
qty=10,
|
||||
rate=100000,
|
||||
do_not_save=True,
|
||||
)
|
||||
@@ -35,17 +28,6 @@ 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
|
||||
@@ -273,104 +255,3 @@ 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"},
|
||||
)
|
||||
|
||||
@@ -22,12 +22,6 @@ def boot_session(bootinfo):
|
||||
frappe.get_single_value("Selling Settings", "use_legacy_js_reactivity")
|
||||
)
|
||||
bootinfo.sysdefaults.allow_stale = cint(frappe.get_single_value("Accounts Settings", "allow_stale"))
|
||||
bootinfo.sysdefaults.bill_for_rejected_quantity_in_purchase_invoice = cint(
|
||||
frappe.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice")
|
||||
)
|
||||
bootinfo.sysdefaults.set_valuation_rate_for_rejected_materials = cint(
|
||||
frappe.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
|
||||
)
|
||||
bootinfo.sysdefaults.over_billing_allowance = frappe.get_single_value(
|
||||
"Accounts Settings", "over_billing_allowance"
|
||||
)
|
||||
|
||||
@@ -3,10 +3,10 @@ import json
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Locate, Sum
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt
|
||||
from pypika import Order
|
||||
from pypika.functions import Coalesce, Concat, Lower
|
||||
from pypika.functions import Coalesce
|
||||
|
||||
from erpnext.deprecation_dumpster import deprecated
|
||||
|
||||
@@ -64,51 +64,36 @@ class DeprecatedSerialNoValuation:
|
||||
incoming_values += self.serial_no_incoming_rate[serial_no]
|
||||
continue
|
||||
|
||||
for sle in self.get_last_inward_sle_for_serial_no(serial_no, posting_datetime):
|
||||
table = frappe.qb.DocType("Stock Ledger Entry")
|
||||
stock_ledgers = (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.incoming_rate, table.actual_qty, table.stock_value_difference)
|
||||
.where(
|
||||
(
|
||||
(table.serial_no == serial_no)
|
||||
| (table.serial_no.like(serial_no + "\n%"))
|
||||
| (table.serial_no.like("%\n" + serial_no))
|
||||
| (table.serial_no.like("%\n" + serial_no + "\n%"))
|
||||
)
|
||||
& (table.item_code == self.sle.item_code)
|
||||
& (table.company == self.sle.company)
|
||||
& (table.warehouse == self.sle.warehouse)
|
||||
& (table.serial_and_batch_bundle.isnull())
|
||||
& (table.actual_qty > 0)
|
||||
& (table.is_cancelled == 0)
|
||||
& table.posting_datetime
|
||||
<= posting_datetime
|
||||
)
|
||||
.orderby(table.posting_datetime, order=Order.desc)
|
||||
.limit(1)
|
||||
).run(as_dict=1)
|
||||
|
||||
for sle in stock_ledgers:
|
||||
self.serial_no_incoming_rate[serial_no] += flt(sle.incoming_rate)
|
||||
incoming_values += self.serial_no_incoming_rate[serial_no]
|
||||
|
||||
return incoming_values
|
||||
|
||||
def get_last_inward_sle_for_serial_no(self, serial_no, posting_datetime):
|
||||
table = frappe.qb.DocType("Stock Ledger Entry")
|
||||
|
||||
serial_no_column = table.serial_no
|
||||
needle = serial_no
|
||||
padded_needle = f"\n{serial_no}\n"
|
||||
|
||||
if frappe.db.db_type != "mariadb":
|
||||
# Locate maps to strpos on PostgreSQL and instr on SQLite, both case sensitive, while
|
||||
# MariaDB compares serial_no under a case insensitive collation. Lower both operands so
|
||||
# a mixed case serial no resolves the same legacy Stock Ledger Entry on every database.
|
||||
serial_no_column = Lower(serial_no_column)
|
||||
needle = needle.lower()
|
||||
padded_needle = padded_needle.lower()
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.incoming_rate, table.actual_qty, table.stock_value_difference)
|
||||
.where(
|
||||
(table.item_code == self.sle.item_code)
|
||||
& (table.company == self.sle.company)
|
||||
& (table.warehouse == self.sle.warehouse)
|
||||
& (table.posting_datetime <= posting_datetime)
|
||||
& (table.is_cancelled == 0)
|
||||
& (table.actual_qty > 0)
|
||||
& (table.serial_and_batch_bundle.isnull())
|
||||
& (Locate(needle, serial_no_column) > 0)
|
||||
& (Locate(padded_needle, Concat("\n", serial_no_column, "\n")) > 0)
|
||||
)
|
||||
.orderby(table.posting_datetime, order=Order.desc)
|
||||
.orderby(table.creation, order=Order.desc)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if frappe.db.db_type == "mariadb":
|
||||
query = query.force_index("item_code_warehouse_posting_datetime_creation_index")
|
||||
|
||||
return query.run(as_dict=1)
|
||||
|
||||
|
||||
class DeprecatedBatchNoValuation:
|
||||
@deprecated(
|
||||
|
||||
@@ -97,27 +97,6 @@ class TestBin(ERPNextTestSuite):
|
||||
self.assertEqual(bin.valuation_rate, 0)
|
||||
self.assertEqual(bin.stock_value, 0)
|
||||
|
||||
def test_cancelling_transfer_restores_bin_stock_value(self):
|
||||
"""Cancelling a transfer must put back the stock value of both bins, not just the quantity."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item_code = make_item().name
|
||||
source = "_Test Warehouse - _TC"
|
||||
target = "_Test Warehouse 1 - _TC"
|
||||
make_stock_entry(item_code=item_code, target=source, qty=10, rate=100, posting_time="01:00:00")
|
||||
make_stock_entry(item_code=item_code, target=target, qty=10, rate=200, posting_time="02:00:00")
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item_code, source=source, target=target, qty=5, posting_time="03:00:00"
|
||||
)
|
||||
se.cancel()
|
||||
|
||||
for warehouse, valuation_rate, stock_value in ((source, 100, 1000), (target, 200, 2000)):
|
||||
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
self.assertEqual(bin.actual_qty, 10)
|
||||
self.assertEqual(bin.valuation_rate, valuation_rate)
|
||||
self.assertEqual(bin.stock_value, stock_value)
|
||||
|
||||
def test_deleting_last_voucher_resets_bin(self):
|
||||
"""Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
@@ -1804,229 +1804,6 @@ 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
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
"column_break_37",
|
||||
"has_serial_no",
|
||||
"serial_no_series",
|
||||
"use_serial_no_wise_valuation",
|
||||
"variants_section",
|
||||
"variant_of",
|
||||
"variant_based_on",
|
||||
@@ -388,7 +387,6 @@
|
||||
"fieldname": "valuation_method",
|
||||
"fieldtype": "Select",
|
||||
"label": "Valuation Method",
|
||||
"description": "Serialized items are valued at Moving Average once stock transactions exist and Serial No Wise Valuation is disabled.",
|
||||
"options": "\nFIFO\nMoving Average\nLIFO\nStandard Cost"
|
||||
},
|
||||
{
|
||||
@@ -531,15 +529,6 @@
|
||||
"label": "Serial Number Series",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.is_stock_item && doc.has_serial_no",
|
||||
"description": "Value every outward movement at each Serial No's own incoming rate. If unchecked, the item is valued at Moving Average once stock transactions exist. Cannot be enabled once Serial Nos exist for this item.",
|
||||
"fieldname": "use_serial_no_wise_valuation",
|
||||
"fieldtype": "Check",
|
||||
"label": "Use Serial No Wise Valuation",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "attributes",
|
||||
@@ -1129,7 +1118,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-09-16 12:00:00.000000",
|
||||
"modified": "2026-09-07 19:53:27.830229",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -146,7 +146,6 @@ class Item(Document):
|
||||
taxes: DF.Table[ItemTax]
|
||||
total_projected_qty: DF.Float
|
||||
uoms: DF.Table[UOMConversionDetail]
|
||||
use_serial_no_wise_valuation: DF.Check
|
||||
valuation_method: DF.Literal["", "FIFO", "Moving Average", "LIFO", "Standard Cost"]
|
||||
valuation_rate: DF.Currency
|
||||
variant_based_on: DF.Literal["Item Attribute", "Manufacturer"]
|
||||
@@ -245,8 +244,6 @@ class Item(Document):
|
||||
self.validate_auto_reorder_enabled_in_stock_settings()
|
||||
self.cant_change()
|
||||
self.validate_serialized_change_with_bundle()
|
||||
self.validate_serial_no_wise_valuation()
|
||||
self.set_valuation_method_for_serial_no_wise_valuation()
|
||||
self.validate_standard_cost_change()
|
||||
self.validate_item_tax_net_rate_range()
|
||||
|
||||
@@ -254,11 +251,8 @@ class Item(Document):
|
||||
self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group")
|
||||
|
||||
def on_update(self):
|
||||
from erpnext.stock.utils import clear_valuation_method_cache
|
||||
|
||||
self.update_variants()
|
||||
self.update_item_price()
|
||||
clear_valuation_method_cache()
|
||||
|
||||
def validate_description(self):
|
||||
"""Clean HTML description if set"""
|
||||
@@ -1167,49 +1161,6 @@ class Item(Document):
|
||||
|
||||
frappe.throw(msg, title=_("Linked with submitted documents"))
|
||||
|
||||
def validate_serial_no_wise_valuation(self):
|
||||
if self.is_new() or not self._doc_before_save:
|
||||
return
|
||||
|
||||
if not self.use_serial_no_wise_valuation or self._doc_before_save.use_serial_no_wise_valuation:
|
||||
return
|
||||
|
||||
if frappe.db.exists("Serial No", {"item_code": self.name}):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Serial No Wise Valuation cannot be enabled for Item {0} because Serial Nos already exist for it. Valuation for those Serial Nos was not tracked, so enabling it now would value outward entries incorrectly."
|
||||
).format(frappe.bold(self.name)),
|
||||
title=_("Serial Nos Exist"),
|
||||
)
|
||||
|
||||
def set_valuation_method_for_serial_no_wise_valuation(self):
|
||||
if not self.has_serial_no or self.use_serial_no_wise_valuation:
|
||||
return
|
||||
|
||||
# Only the switch turning off forces Moving Average, because the per serial costs already in the
|
||||
# ledger cannot be replayed as a FIFO queue. An item that has always had the switch off keeps its
|
||||
# own method, so an unrelated save cannot silently revalue a ledger nothing reposts.
|
||||
if self._doc_before_save and not self._doc_before_save.use_serial_no_wise_valuation:
|
||||
return
|
||||
|
||||
if not frappe.db.exists("Stock Ledger Entry", {"item_code": self.name, "is_cancelled": 0}):
|
||||
return
|
||||
|
||||
if (
|
||||
not self.is_new()
|
||||
and self._doc_before_save
|
||||
and self.has_value_changed("valuation_method")
|
||||
and self.valuation_method in ("FIFO", "LIFO", "Standard Cost")
|
||||
):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Valuation Method for Item {0} must be Moving Average because Serial No Wise Valuation is disabled. Enable Serial No Wise Valuation to use FIFO, LIFO or Standard Cost."
|
||||
).format(frappe.bold(self.name)),
|
||||
title=_("Invalid Valuation Method"),
|
||||
)
|
||||
|
||||
self.valuation_method = "Moving Average"
|
||||
|
||||
def validate_serialized_change_with_bundle(self):
|
||||
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
|
||||
for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""
|
||||
|
||||
@@ -73,42 +73,24 @@ 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") {
|
||||
var items = this.frm.doc.items || [];
|
||||
items.forEach((item) => {
|
||||
total_item_cost += flt(item[based_on]);
|
||||
$.each(this.frm.doc.items || [], function (i, d) {
|
||||
total_item_cost += flt(d[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;
|
||||
items.forEach((item) => {
|
||||
$.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);
|
||||
item.applicable_charges = flt(
|
||||
(flt(item[based_on]) * flt(this.frm.doc.total_taxes_and_charges)) /
|
||||
flt(total_item_cost),
|
||||
item.applicable_charges,
|
||||
precision("applicable_charges", item)
|
||||
);
|
||||
total_charges += item.applicable_charges;
|
||||
@@ -116,7 +98,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);
|
||||
items.slice(-1)[0].applicable_charges += diff;
|
||||
this.frm.doc.items.slice(-1)[0].applicable_charges += diff;
|
||||
}
|
||||
refresh_field("items");
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class LandedCostVoucher(Document):
|
||||
item.item_code = d.item_code
|
||||
item.description = d.description
|
||||
item.qty = d.qty
|
||||
item.rate = d.base_rate
|
||||
item.rate = d.get("base_rate") or d.get("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,24 +306,22 @@ class LandedCostVoucher(Document):
|
||||
|
||||
def set_applicable_charges_on_item(self):
|
||||
if self.get("taxes") and self.distribute_charges_based_on != "Distribute Manually":
|
||||
items = self.get("items")
|
||||
total_item_cost = 0.0
|
||||
total_charges = 0.0
|
||||
item_count = 0
|
||||
based_on_field = frappe.scrub(self.distribute_charges_based_on)
|
||||
|
||||
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))
|
||||
|
||||
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"):
|
||||
total_item_cost += item.get(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'"
|
||||
)
|
||||
)
|
||||
|
||||
item.applicable_charges = flt(
|
||||
flt(item.get(based_on_field))
|
||||
* (flt(self.total_taxes_and_charges) / flt(total_item_cost)),
|
||||
@@ -568,8 +566,8 @@ def get_pr_items(purchase_receipt):
|
||||
query = query.where(pr_item.is_finished_item == 1)
|
||||
else:
|
||||
query = query.select(
|
||||
pr_item.base_net_rate.as_("base_rate"),
|
||||
pr_item.base_net_amount.as_("base_amount"),
|
||||
pr_item.base_rate,
|
||||
pr_item.base_amount,
|
||||
pr_item.is_fixed_asset,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,103 +28,6 @@ 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.
|
||||
|
||||
@@ -80,7 +80,6 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
|
||||
if (
|
||||
doc.get("is_return")
|
||||
and item.return_qty_from_rejected_warehouse
|
||||
and not doc.is_internal_transfer()
|
||||
and not frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
)
|
||||
@@ -102,15 +101,11 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
|
||||
|
||||
outgoing_amount = item.base_net_amount
|
||||
if doc.is_internal_transfer() and item.valuation_rate:
|
||||
outgoing_amount = -1 * flt(
|
||||
get_stock_value_difference(doc.name, item.name, item.from_warehouse)
|
||||
)
|
||||
outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse))
|
||||
credit_amount = outgoing_amount
|
||||
|
||||
if (
|
||||
item.get("rejected_qty")
|
||||
and not doc.is_internal_transfer()
|
||||
and frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
|
||||
if item.get("rejected_qty") and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
):
|
||||
outgoing_amount += get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
|
||||
credit_amount = outgoing_amount
|
||||
@@ -262,7 +257,9 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
|
||||
valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount")
|
||||
)
|
||||
|
||||
if item.get("rejected_qty") and self.is_rejected_material_valued():
|
||||
if item.get("rejected_qty") and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
):
|
||||
rejected_item_cost = get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
|
||||
divisional_loss -= rejected_item_cost
|
||||
|
||||
@@ -350,7 +347,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
|
||||
make_sub_contracting_gl_entries(d)
|
||||
make_divisional_loss_gl_entry(d, outgoing_amount)
|
||||
elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or (
|
||||
not self.is_rejected_material_valued()
|
||||
not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
|
||||
and d.rejected_warehouse
|
||||
and d.rejected_warehouse not in warehouse_with_no_account
|
||||
):
|
||||
@@ -359,7 +356,9 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
|
||||
if d.is_fixed_asset and d.landed_cost_voucher_amount:
|
||||
doc.update_assets(d, d.valuation_rate)
|
||||
|
||||
if d.rejected_qty and self.is_rejected_material_valued():
|
||||
if d.rejected_qty and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
):
|
||||
stock_asset_rbnb = (
|
||||
doc.get_company_default("asset_received_but_not_billed")
|
||||
if d.is_fixed_asset
|
||||
@@ -381,16 +380,6 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
|
||||
+ "\n".join(warehouse_with_no_account)
|
||||
)
|
||||
|
||||
def is_rejected_material_valued(self) -> bool:
|
||||
"""Rejected material carries stock value when Buying Settings asks for it, and always on an
|
||||
internal transfer, where that value is credited out of the in-transit warehouse."""
|
||||
if self.doc.is_internal_transfer():
|
||||
return True
|
||||
|
||||
return bool(
|
||||
frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
|
||||
)
|
||||
|
||||
def get_divisional_loss_account(self, item, stock_asset_rbnb):
|
||||
"""Account that absorbs the difference between the document value and the value actually
|
||||
booked into stock. For a Standard Cost item this difference is a purchase price variance
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@ from frappe.utils import (
|
||||
from frappe.utils.csvutils import build_csv_response
|
||||
|
||||
from erpnext.buying.doctype.buying_settings.buying_settings import (
|
||||
is_material_from_in_transit_warehouse,
|
||||
is_rejected_material_valued,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem
|
||||
@@ -414,9 +413,8 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
def set_valuation_rate_for_return_entry(self, return_against, row, save=False, prev_sle=None):
|
||||
if valuation_details := self.get_valuation_rate_for_return_entry(return_against):
|
||||
from erpnext.stock.utils import get_valuation_method, is_serial_no_wise_valuation_disabled
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
skip_rate_update = is_serial_no_wise_valuation_disabled(self.item_code)
|
||||
valuation_method = get_valuation_method(self.item_code, self.company)
|
||||
|
||||
# An outward return must go out at the batch's current average rate for a
|
||||
@@ -445,9 +443,6 @@ class SerialandBatchBundle(Document):
|
||||
if valuation_details:
|
||||
self.validate_returned_serial_batch_no(return_against, row, valuation_details)
|
||||
|
||||
if skip_rate_update:
|
||||
continue
|
||||
|
||||
if row.serial_no:
|
||||
valuation_rate = valuation_details["serial_nos"].get(row.serial_no)
|
||||
else:
|
||||
@@ -704,10 +699,7 @@ class SerialandBatchBundle(Document):
|
||||
)
|
||||
|
||||
def set_incoming_rate_for_outward_transaction(self, row=None, save=False, allow_negative_stock=False):
|
||||
from erpnext.stock.utils import get_valuation_method, is_serial_no_wise_valuation_disabled
|
||||
|
||||
if is_serial_no_wise_valuation_disabled(self.item_code):
|
||||
return
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
sle = self.get_sle_for_outward_transaction()
|
||||
|
||||
@@ -910,14 +902,7 @@ class SerialandBatchBundle(Document):
|
||||
if batches and valuation_method == "FIFO":
|
||||
stock_queue = parse_json(prev_sle.stock_queue)
|
||||
|
||||
values_rejected_material = is_rejected_material_valued(self.voucher_type, self.voucher_detail_no)
|
||||
|
||||
if self.is_rejected and is_material_from_in_transit_warehouse(
|
||||
self.voucher_type, self.voucher_detail_no
|
||||
):
|
||||
# Rejected material of a transfer keeps the value it had in transit. A charge spread
|
||||
# over the accepted quantity does not belong to it.
|
||||
rate = flt(self.get_transit_rate(row)) or rate
|
||||
set_valuation_rate_for_rejected_materials = is_rejected_material_valued(self.voucher_type)
|
||||
|
||||
precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate")
|
||||
for d in self.entries:
|
||||
@@ -925,7 +910,7 @@ class SerialandBatchBundle(Document):
|
||||
if valuation_method == "FIFO" and d.batch_no in batches:
|
||||
fifo_batch_wise_val = False
|
||||
|
||||
if self.is_rejected and not values_rejected_material:
|
||||
if self.is_rejected and not set_valuation_rate_for_rejected_materials:
|
||||
rate = 0.0
|
||||
elif (
|
||||
(flt(d.incoming_rate, precision) == flt(rate, precision))
|
||||
@@ -1175,7 +1160,7 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
def reset_qty(self, row, qty_field=None):
|
||||
qty_field = self.get_qty_field(row, qty_field=qty_field)
|
||||
qty = abs(flt(self.get_row_qty(row, qty_field), self.precision("total_qty")))
|
||||
qty = abs(flt(row.get(qty_field), self.precision("total_qty")))
|
||||
|
||||
idx = None
|
||||
while qty > 0:
|
||||
@@ -1202,38 +1187,20 @@ class SerialandBatchBundle(Document):
|
||||
self.flags.ignore_links = True
|
||||
self.save()
|
||||
|
||||
def get_row_qty(self, row, qty_field) -> float:
|
||||
"""What the row holds in the units a package counts in."""
|
||||
if qty_field == "qty" and row.get("stock_qty"):
|
||||
return flt(row.get("stock_qty"))
|
||||
|
||||
if qty_field == "rejected_qty":
|
||||
return flt(row.get(qty_field)) * flt(row.get("conversion_factor") or 1)
|
||||
|
||||
return flt(row.get(qty_field))
|
||||
|
||||
def validate_quantity(self, row, qty_field=None):
|
||||
qty_field = self.get_qty_field(row, qty_field=qty_field)
|
||||
qty = self.get_row_qty(row, qty_field)
|
||||
qty = row.get(qty_field)
|
||||
if qty_field == "qty" and row.get("stock_qty"):
|
||||
qty = row.get("stock_qty")
|
||||
|
||||
precision = row.precision(qty_field)
|
||||
if abs(abs(flt(self.total_qty, precision)) - abs(flt(qty, precision))) > 0.01:
|
||||
total_qty = frappe.format_value(abs(flt(self.total_qty)), "Float", row)
|
||||
set_qty = frappe.format_value(abs(flt(qty)), "Float", row)
|
||||
set_qty = frappe.format_value(abs(flt(row.get(qty_field))), "Float", row)
|
||||
self.throw_error_message(
|
||||
f"Total quantity {total_qty} in the Serial and Batch Bundle {bold(self.name)} does not match with the quantity {set_qty} for the Item {bold(self.item_code)} in the {self.voucher_type} # {self.voucher_no}"
|
||||
)
|
||||
|
||||
def get_transit_rate(self, row) -> float:
|
||||
"""What the material was worth on its way into the in-transit warehouse."""
|
||||
if row and row.get("sales_incoming_rate"):
|
||||
return flt(row.get("sales_incoming_rate"))
|
||||
|
||||
if not (self.voucher_detail_no and self.voucher_no):
|
||||
return 0.0
|
||||
|
||||
return flt(frappe.db.get_value(self.child_table, self.voucher_detail_no, "sales_incoming_rate"))
|
||||
|
||||
def get_qty_field(self, row, qty_field=None) -> str:
|
||||
if not qty_field:
|
||||
qty_field = "qty"
|
||||
@@ -1242,7 +1209,7 @@ class SerialandBatchBundle(Document):
|
||||
qty_field = "consumed_qty"
|
||||
elif row.get("doctype") == "Stock Entry Detail":
|
||||
qty_field = "transfer_qty"
|
||||
elif row.get("doctype") in ["Sales Invoice Item", "Purchase Invoice Item"] and qty_field == "qty":
|
||||
elif row.get("doctype") in ["Sales Invoice Item", "Purchase Invoice Item"]:
|
||||
qty_field = "stock_qty"
|
||||
|
||||
return qty_field
|
||||
|
||||
@@ -1682,426 +1682,6 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
)
|
||||
self.assertRaises(NegativeStockError, backdated.submit)
|
||||
|
||||
def make_serial_item_for_valuation(self, item_code, use_serial_no_wise_valuation):
|
||||
return make_item(
|
||||
item_code,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_serial_no": 1,
|
||||
"serial_no_series": item_code + "-.####",
|
||||
"valuation_method": "FIFO" if use_serial_no_wise_valuation else "Moving Average",
|
||||
"use_serial_no_wise_valuation": use_serial_no_wise_valuation,
|
||||
},
|
||||
)
|
||||
|
||||
def receive_serial_stock(self, item_code, qty, rate, warehouse, posting_date=None):
|
||||
entry = make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=qty,
|
||||
basic_rate=rate,
|
||||
posting_date=posting_date,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
|
||||
return get_serial_nos_from_bundle(entry.items[0].serial_and_batch_bundle)
|
||||
|
||||
def issue_serial_no(self, item_code, serial_no, warehouse, posting_date=None):
|
||||
return make_stock_entry(
|
||||
item_code=item_code,
|
||||
source=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
posting_date=posting_date,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
|
||||
def get_stock_value_difference(self, voucher_no):
|
||||
return frappe.db.get_value(
|
||||
"Stock Ledger Entry", {"voucher_no": voucher_no, "is_cancelled": 0}, "stock_value_difference"
|
||||
)
|
||||
|
||||
def test_serial_no_wise_valuation_uses_serial_rate_when_enabled(self):
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation On", 1)
|
||||
|
||||
self.receive_serial_stock(item.name, 2, 100, warehouse)
|
||||
newer_serial_nos = self.receive_serial_stock(item.name, 2, 200, warehouse)
|
||||
|
||||
issue = self.issue_serial_no(item.name, newer_serial_nos[-1], warehouse)
|
||||
|
||||
self.assertEqual(flt(self.get_stock_value_difference(issue.name)), -200.0)
|
||||
|
||||
def test_serial_no_wise_valuation_uses_item_valuation_method_when_disabled(self):
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Off", 0)
|
||||
|
||||
self.receive_serial_stock(item.name, 2, 100, warehouse)
|
||||
newer_serial_nos = self.receive_serial_stock(item.name, 2, 200, warehouse)
|
||||
|
||||
issue = self.issue_serial_no(item.name, newer_serial_nos[-1], warehouse)
|
||||
|
||||
self.assertEqual(flt(self.get_stock_value_difference(issue.name)), -150.0)
|
||||
|
||||
def test_outward_bundle_rate_not_set_when_serial_no_wise_valuation_disabled(self):
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No Rate", 0)
|
||||
|
||||
serial_nos = self.receive_serial_stock(item.name, 2, 100, warehouse)
|
||||
issue = self.issue_serial_no(item.name, serial_nos[-1], warehouse)
|
||||
|
||||
rates = frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": issue.items[0].serial_and_batch_bundle},
|
||||
pluck="incoming_rate",
|
||||
)
|
||||
|
||||
self.assertTrue(rates)
|
||||
for rate in rates:
|
||||
self.assertEqual(flt(rate), 0.0)
|
||||
|
||||
def test_cannot_enable_serial_no_wise_valuation_when_serial_nos_exist(self):
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Toggle", 1)
|
||||
self.receive_serial_stock(item.name, 1, 100, warehouse)
|
||||
|
||||
item.reload()
|
||||
item.use_serial_no_wise_valuation = 0
|
||||
item.save()
|
||||
|
||||
item.reload()
|
||||
item.use_serial_no_wise_valuation = 1
|
||||
self.assertRaises(frappe.ValidationError, item.save)
|
||||
|
||||
def make_purchase_return_for_serial_no(self, item_code, serial_no, receipt):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
entry = make_return_doc("Purchase Receipt", receipt.name)
|
||||
entry.items[0].qty = -1
|
||||
entry.items[0].received_qty = -1
|
||||
for row in entry.items:
|
||||
row.serial_and_batch_bundle = None
|
||||
row.use_serial_batch_fields = 1
|
||||
row.serial_no = serial_no
|
||||
|
||||
entry.save()
|
||||
entry.submit()
|
||||
|
||||
return entry
|
||||
|
||||
def test_purchase_return_uses_item_valuation_method_when_disabled(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Pur Return Off", 0)
|
||||
|
||||
make_purchase_receipt(item_code=item.name, qty=2, rate=100, warehouse=warehouse)
|
||||
costlier_receipt = make_purchase_receipt(item_code=item.name, qty=2, rate=200, warehouse=warehouse)
|
||||
serial_nos = get_serial_nos_from_bundle(costlier_receipt.items[0].serial_and_batch_bundle)
|
||||
|
||||
entry = self.make_purchase_return_for_serial_no(item.name, serial_nos[-1], costlier_receipt)
|
||||
|
||||
self.assertEqual(flt(self.get_stock_value_difference(entry.name)), -150.0)
|
||||
|
||||
def test_purchase_return_uses_serial_rate_when_enabled(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Pur Return On", 1)
|
||||
|
||||
make_purchase_receipt(item_code=item.name, qty=2, rate=100, warehouse=warehouse)
|
||||
costlier_receipt = make_purchase_receipt(item_code=item.name, qty=2, rate=200, warehouse=warehouse)
|
||||
serial_nos = get_serial_nos_from_bundle(costlier_receipt.items[0].serial_and_batch_bundle)
|
||||
|
||||
entry = self.make_purchase_return_for_serial_no(item.name, serial_nos[-1], costlier_receipt)
|
||||
|
||||
self.assertEqual(flt(self.get_stock_value_difference(entry.name)), -200.0)
|
||||
|
||||
def deliver_serial_no(self, item_code, serial_no, warehouse, posting_date=None):
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
return create_delivery_note(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
posting_date=posting_date,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
|
||||
def repost_item_and_warehouse(self, item_code, warehouse, posting_date):
|
||||
from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import repost
|
||||
|
||||
riv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Item and Warehouse",
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": posting_date,
|
||||
"posting_time": "00:00:01",
|
||||
"company": frappe.get_cached_value("Warehouse", warehouse, "company"),
|
||||
}
|
||||
)
|
||||
riv.flags.dont_run_in_test = True
|
||||
riv.submit()
|
||||
riv.reload()
|
||||
repost(riv)
|
||||
riv.reload()
|
||||
self.assertEqual(riv.status, "Completed")
|
||||
|
||||
def assert_outward_sle_at_moving_average(self, voucher_no, warehouse, rate, qty_after_transaction):
|
||||
sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": voucher_no, "warehouse": warehouse, "is_cancelled": 0},
|
||||
["outgoing_rate", "valuation_rate", "stock_value", "stock_value_difference"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
self.assertEqual(flt(sle.outgoing_rate), rate, voucher_no)
|
||||
self.assertEqual(flt(sle.valuation_rate), rate, voucher_no)
|
||||
self.assertEqual(flt(sle.stock_value), rate * qty_after_transaction, voucher_no)
|
||||
self.assertEqual(flt(sle.stock_value_difference), -rate, voucher_no)
|
||||
|
||||
def test_repost_values_outward_entries_at_moving_average_when_disabled(self):
|
||||
"""A repost must value plain outward entries at the moving average once the switch is off. The
|
||||
serial rates are seeded because such entries come from ledgers written before the switch."""
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Repost Outward", 1)
|
||||
posting_date = add_days(today(), -10)
|
||||
|
||||
cheaper = self.receive_serial_stock(item.name, 2, 100, warehouse, posting_date)
|
||||
costlier = self.receive_serial_stock(item.name, 2, 200, warehouse, add_days(posting_date, 1))
|
||||
|
||||
issue = self.issue_serial_no(item.name, costlier[-1], warehouse, add_days(posting_date, 2))
|
||||
delivery = self.deliver_serial_no(item.name, cheaper[-1], warehouse, add_days(posting_date, 3))
|
||||
|
||||
for voucher_no, serial_rate in ((issue.name, 200.0), (delivery.name, 100.0)):
|
||||
sle_name = frappe.db.get_value(
|
||||
"Stock Ledger Entry", {"voucher_no": voucher_no, "is_cancelled": 0}, "name"
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle_name, "outgoing_rate", serial_rate, update_modified=False
|
||||
)
|
||||
|
||||
item.reload()
|
||||
item.use_serial_no_wise_valuation = 0
|
||||
item.save()
|
||||
|
||||
self.repost_item_and_warehouse(item.name, warehouse, posting_date)
|
||||
|
||||
# 2 @ 100 plus 2 @ 200 makes the moving average 150, and neither outward entry may move it
|
||||
self.assert_outward_sle_at_moving_average(issue.name, warehouse, 150.0, 3.0)
|
||||
self.assert_outward_sle_at_moving_average(delivery.name, warehouse, 150.0, 2.0)
|
||||
|
||||
def test_repost_values_purchase_return_at_moving_average_when_disabled(self):
|
||||
"""Same as above for a purchase return: it must leave the remaining stock at the moving average,
|
||||
not at the returned serial's own rate."""
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Repost Return", 1)
|
||||
posting_date = add_days(today(), -10)
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item.name, qty=2, rate=100, warehouse=warehouse, posting_date=posting_date
|
||||
)
|
||||
costlier_receipt = make_purchase_receipt(
|
||||
item_code=item.name,
|
||||
qty=2,
|
||||
rate=200,
|
||||
warehouse=warehouse,
|
||||
posting_date=add_days(posting_date, 1),
|
||||
)
|
||||
serial_nos = get_serial_nos_from_bundle(costlier_receipt.items[0].serial_and_batch_bundle)
|
||||
|
||||
entry = self.make_purchase_return_for_serial_no(item.name, serial_nos[-1], costlier_receipt)
|
||||
self.assertEqual(flt(self.get_stock_value_difference(entry.name)), -200.0)
|
||||
|
||||
item.reload()
|
||||
item.use_serial_no_wise_valuation = 0
|
||||
item.save()
|
||||
|
||||
self.repost_item_and_warehouse(item.name, warehouse, posting_date)
|
||||
|
||||
self.assert_outward_sle_at_moving_average(entry.name, warehouse, 150.0, 3.0)
|
||||
|
||||
def test_valuation_method_forced_to_moving_average_when_disabled(self):
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Forced MA", 1)
|
||||
self.receive_serial_stock(item.name, 1, 100, "_Test Warehouse - _TC")
|
||||
|
||||
item.reload()
|
||||
item.valuation_method = "FIFO"
|
||||
item.use_serial_no_wise_valuation = 0
|
||||
item.save()
|
||||
|
||||
item.reload()
|
||||
self.assertEqual(item.valuation_method, "Moving Average")
|
||||
|
||||
def test_valuation_method_kept_when_disabled_without_stock_transactions(self):
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No MA Yet", 1)
|
||||
|
||||
item.reload()
|
||||
item.valuation_method = "FIFO"
|
||||
item.use_serial_no_wise_valuation = 0
|
||||
item.save()
|
||||
|
||||
item.reload()
|
||||
self.assertEqual(item.valuation_method, "FIFO")
|
||||
|
||||
def test_valuation_method_kept_when_disabled_and_saved_after_stock_transactions(self):
|
||||
"""An item that has always had the switch off keeps its own valuation method. Only turning the
|
||||
switch off forces Moving Average, so an unrelated save cannot silently revalue the ledger."""
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Keeps FIFO On Save", 0)
|
||||
item.reload()
|
||||
item.valuation_method = "FIFO"
|
||||
item.save()
|
||||
|
||||
self.receive_serial_stock(item.name, 2, 100, "_Test Warehouse - _TC")
|
||||
self.receive_serial_stock(item.name, 2, 200, "_Test Warehouse - _TC")
|
||||
|
||||
item.reload()
|
||||
item.description = "saved for an unrelated reason"
|
||||
item.save()
|
||||
|
||||
item.reload()
|
||||
self.assertEqual(item.valuation_method, "FIFO")
|
||||
|
||||
def test_fifo_allowed_when_disabled_without_stock_transactions(self):
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation FIFO Ok", 0)
|
||||
|
||||
item.reload()
|
||||
item.valuation_method = "FIFO"
|
||||
item.save()
|
||||
|
||||
item.reload()
|
||||
self.assertEqual(item.valuation_method, "FIFO")
|
||||
|
||||
def test_first_transaction_uses_moving_average_when_disabled(self):
|
||||
from collections import defaultdict
|
||||
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation First Txn", 0)
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
item.reload()
|
||||
item.valuation_method = "FIFO"
|
||||
item.save()
|
||||
|
||||
previous_cache = getattr(frappe.local, "request_cache", None)
|
||||
self.addCleanup(setattr, frappe.local, "request_cache", previous_cache)
|
||||
frappe.local.request_cache = defaultdict(dict)
|
||||
|
||||
# Any method may be stored and used while the item has no ledger. Reading it here also
|
||||
# primes the request cache with FIFO, the way validate() does before entries exist.
|
||||
self.assertEqual(item.valuation_method, "FIFO")
|
||||
self.assertEqual(get_valuation_method(item.name), "FIFO")
|
||||
|
||||
serial_nos = self.receive_serial_stock(item.name, 1, 100, warehouse)
|
||||
self.receive_serial_stock(item.name, 1, 200, warehouse)
|
||||
|
||||
# The issue must be valued at the moving average of 150, not the FIFO rate of 100, even
|
||||
# though the cache primed above still holds FIFO.
|
||||
entry = self.issue_serial_no(item.name, serial_nos[0], warehouse)
|
||||
stock_value_difference = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": entry.name, "is_cancelled": 0},
|
||||
"stock_value_difference",
|
||||
)
|
||||
self.assertEqual(flt(stock_value_difference, 2), -100.0)
|
||||
|
||||
# Posting stock does not save the item, so the stored method stays FIFO while the
|
||||
# effective method is Moving Average now that a ledger exists.
|
||||
item.reload()
|
||||
self.assertEqual(item.valuation_method, "FIFO")
|
||||
frappe.local.request_cache = defaultdict(dict)
|
||||
self.assertEqual(get_valuation_method(item.name), "FIFO")
|
||||
|
||||
def test_legacy_serial_no_lookup_is_case_insensitive(self):
|
||||
# MariaDB matches serial_no under a case insensitive collation, PostgreSQL does not.
|
||||
# This asserts the lookup behaves the same on both; it can only fail on PostgreSQL.
|
||||
from erpnext.stock.deprecated_serial_batch import DeprecatedSerialNoValuation
|
||||
|
||||
item = self.make_serial_item_for_valuation("_Test Legacy Serial Case", 1)
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
serial_no = self.receive_serial_stock(item.name, 1, 100, warehouse)[0]
|
||||
|
||||
# Rewrite the receipt into the pre-bundle representation.
|
||||
sles = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"item_code": item.name, "is_cancelled": 0},
|
||||
fields=["name", "posting_datetime"],
|
||||
)
|
||||
for sle in sles:
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, {"serial_and_batch_bundle": None, "serial_no": serial_no}
|
||||
)
|
||||
|
||||
class LegacyLookup(DeprecatedSerialNoValuation):
|
||||
def __init__(self, sle):
|
||||
self.sle = sle
|
||||
|
||||
lookup = LegacyLookup(
|
||||
frappe._dict(
|
||||
item_code=item.name,
|
||||
company=frappe.get_cached_value("Warehouse", warehouse, "company"),
|
||||
warehouse=warehouse,
|
||||
)
|
||||
)
|
||||
posting_datetime = sles[0].posting_datetime
|
||||
|
||||
self.assertTrue(lookup.get_last_inward_sle_for_serial_no(serial_no, posting_datetime))
|
||||
self.assertTrue(lookup.get_last_inward_sle_for_serial_no(serial_no.swapcase(), posting_datetime))
|
||||
|
||||
def test_cannot_set_fifo_when_serial_no_wise_valuation_disabled(self):
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No FIFO", 0)
|
||||
self.receive_serial_stock(item.name, 1, 100, "_Test Warehouse - _TC")
|
||||
|
||||
item.reload()
|
||||
self.assertEqual(item.valuation_method, "Moving Average")
|
||||
|
||||
item.valuation_method = "FIFO"
|
||||
self.assertRaises(frappe.ValidationError, item.save)
|
||||
|
||||
def test_valuation_helpers_not_stale_after_disabling_in_same_request(self):
|
||||
from collections import defaultdict
|
||||
|
||||
from erpnext.stock.utils import get_valuation_method, is_serial_no_wise_valuation_disabled
|
||||
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Cache", 1)
|
||||
self.receive_serial_stock(item.name, 1, 100, "_Test Warehouse - _TC")
|
||||
|
||||
previous_cache = getattr(frappe.local, "request_cache", None)
|
||||
self.addCleanup(setattr, frappe.local, "request_cache", previous_cache)
|
||||
frappe.local.request_cache = defaultdict(dict)
|
||||
|
||||
self.assertEqual(get_valuation_method(item.name), "FIFO")
|
||||
self.assertFalse(is_serial_no_wise_valuation_disabled(item.name))
|
||||
|
||||
item.reload()
|
||||
item.use_serial_no_wise_valuation = 0
|
||||
item.save()
|
||||
|
||||
self.assertEqual(get_valuation_method(item.name), "Moving Average")
|
||||
self.assertTrue(is_serial_no_wise_valuation_disabled(item.name))
|
||||
|
||||
def test_valuation_method_untouched_when_serial_no_wise_valuation_enabled(self):
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Keeps FIFO", 1)
|
||||
|
||||
item.reload()
|
||||
self.assertEqual(item.valuation_method, "FIFO")
|
||||
|
||||
def test_enable_serial_no_wise_valuation_allowed_without_serial_nos(self):
|
||||
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No Serials", 0)
|
||||
|
||||
item.reload()
|
||||
item.use_serial_no_wise_valuation = 1
|
||||
item.save()
|
||||
|
||||
item.reload()
|
||||
self.assertEqual(item.use_serial_no_wise_valuation, 1)
|
||||
|
||||
|
||||
def get_batch_from_bundle(bundle):
|
||||
from erpnext.stock.serial_batch_bundle import get_batch_nos
|
||||
|
||||
@@ -22,46 +22,6 @@ class BaseMaterialTransferStockEntry(BaseStockEntry):
|
||||
if not row.s_warehouse:
|
||||
frappe.throw(_("Source Warehouse is required for item {0}").format(row.item_code))
|
||||
|
||||
self.validate_transit_warehouses()
|
||||
|
||||
def validate_transit_warehouses(self):
|
||||
if not self.doc.add_to_transit:
|
||||
return
|
||||
|
||||
target_warehouses = {row.t_warehouse for row in self.doc.items if row.t_warehouse}
|
||||
if self.doc.to_warehouse:
|
||||
target_warehouses.add(self.doc.to_warehouse)
|
||||
|
||||
if not target_warehouses:
|
||||
return
|
||||
|
||||
transit_warehouses = set(
|
||||
frappe.get_all(
|
||||
"Warehouse",
|
||||
filters={
|
||||
"name": ("in", list(target_warehouses)),
|
||||
"warehouse_type": "Transit",
|
||||
"company": self.doc.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
if self.doc.to_warehouse and self.doc.to_warehouse not in transit_warehouses:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Default Target Warehouse {0} must be a Transit warehouse when Add to Transit is enabled."
|
||||
).format(frappe.bold(self.doc.to_warehouse))
|
||||
)
|
||||
|
||||
for row in self.doc.items:
|
||||
if row.t_warehouse and row.t_warehouse not in transit_warehouses:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Target Warehouse {1} must be a Transit warehouse when Add to Transit is enabled."
|
||||
).format(row.idx, frappe.bold(row.t_warehouse))
|
||||
)
|
||||
|
||||
def validate_same_source_target_warehouse(self):
|
||||
"""
|
||||
Raises: frappe.ValidationError: If warehouses are same and no inventory dimensions differ
|
||||
|
||||
@@ -903,15 +903,22 @@ 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1223,28 +1230,6 @@ 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;
|
||||
|
||||
@@ -1434,10 +1419,7 @@ 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);
|
||||
|
||||
if (!this.frm.doc.__onload?.load_after_mapping) {
|
||||
this.set_default_account("cost_center", "cost_center");
|
||||
}
|
||||
this.set_default_account("cost_center", "cost_center");
|
||||
|
||||
this.frm.refresh_fields("items");
|
||||
}
|
||||
|
||||
@@ -1750,12 +1750,6 @@ 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()
|
||||
@@ -1775,7 +1769,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 = get_remaining_transfer_qty(source_doc) / flt(source_doc.conversion_factor)
|
||||
target_doc.qty = source_doc.qty - source_doc.transferred_qty
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Stock Entry",
|
||||
@@ -1795,7 +1789,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: get_remaining_transfer_qty(doc) > 0,
|
||||
"condition": lambda doc: flt(doc.qty) - flt(doc.transferred_qty) > 0.00001,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
|
||||
@@ -272,8 +272,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
company = "_Test Company"
|
||||
|
||||
create_warehouse("Test From 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 Transit Warehouse")
|
||||
create_warehouse("Test To Warehouse")
|
||||
|
||||
create_item(
|
||||
@@ -324,131 +323,6 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
transit_entry.reload()
|
||||
self.assertEqual(transit_entry.per_transferred, 100)
|
||||
|
||||
def test_end_transit_qty_with_uom_conversion(self):
|
||||
"""transferred_qty is tracked in the stock UOM, so the end transit qty must be converted back."""
|
||||
company = "_Test Company"
|
||||
source_warehouse = "_Test Warehouse - _TC"
|
||||
target_warehouse = "_Test Warehouse 1 - _TC"
|
||||
transit_warehouse = get_in_transit_warehouse(company)
|
||||
|
||||
item_code = make_item(
|
||||
"_Test Transit UOM Conversion Item",
|
||||
{"is_stock_item": 1, "stock_uom": "Nos", "uoms": [{"uom": "Kg", "conversion_factor": 0.5}]},
|
||||
).name
|
||||
|
||||
make_stock_entry(item_code=item_code, target=source_warehouse, qty=100, basic_rate=100)
|
||||
|
||||
transit_entry = make_stock_entry(
|
||||
item_code=item_code,
|
||||
source=source_warehouse,
|
||||
target=transit_warehouse,
|
||||
purpose="Material Transfer",
|
||||
add_to_transit=1,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
do_not_save=True,
|
||||
)
|
||||
transit_entry.items[0].uom = "Kg"
|
||||
transit_entry.items[0].conversion_factor = 0.5
|
||||
transit_entry.save().submit()
|
||||
self.assertEqual(transit_entry.items[0].transfer_qty, 5)
|
||||
|
||||
partial_entry = make_stock_in_entry(transit_entry.name)
|
||||
partial_entry.to_warehouse = target_warehouse
|
||||
partial_entry.items[0].qty = 4
|
||||
partial_entry.items[0].t_warehouse = target_warehouse
|
||||
partial_entry.save().submit()
|
||||
|
||||
remaining_entry = make_stock_in_entry(transit_entry.name)
|
||||
self.assertEqual(remaining_entry.items[0].uom, "Kg")
|
||||
self.assertEqual(remaining_entry.items[0].qty, 6)
|
||||
|
||||
remaining_entry.to_warehouse = target_warehouse
|
||||
remaining_entry.items[0].t_warehouse = target_warehouse
|
||||
remaining_entry.save().submit()
|
||||
|
||||
self.assertFalse(make_stock_in_entry(transit_entry.name).get("items"))
|
||||
|
||||
def test_end_transit_maps_smallest_remaining_qty(self):
|
||||
"""The smallest storable remainder survives binary subtraction, 2.001 - 2 is 0.0009999999999998899."""
|
||||
company = "_Test Company"
|
||||
source_warehouse = "_Test Warehouse - _TC"
|
||||
target_warehouse = "_Test Warehouse 1 - _TC"
|
||||
transit_warehouse = get_in_transit_warehouse(company)
|
||||
|
||||
item_code = make_item(
|
||||
"_Test Transit Fractional Item", {"is_stock_item": 1, "stock_uom": "Litre"}
|
||||
).name
|
||||
smallest_qty = 1 / (10 ** frappe.get_precision("Stock Entry Detail", "transfer_qty"))
|
||||
|
||||
make_stock_entry(item_code=item_code, target=source_warehouse, qty=100, basic_rate=100)
|
||||
|
||||
transit_entry = make_stock_entry(
|
||||
item_code=item_code,
|
||||
source=source_warehouse,
|
||||
target=transit_warehouse,
|
||||
purpose="Material Transfer",
|
||||
add_to_transit=1,
|
||||
qty=2 + smallest_qty,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
partial_entry = make_stock_in_entry(transit_entry.name)
|
||||
partial_entry.to_warehouse = target_warehouse
|
||||
partial_entry.items[0].qty = 2
|
||||
partial_entry.items[0].t_warehouse = target_warehouse
|
||||
partial_entry.save().submit()
|
||||
|
||||
remaining_entry = make_stock_in_entry(transit_entry.name)
|
||||
self.assertEqual(remaining_entry.items[0].qty, smallest_qty)
|
||||
|
||||
def test_add_to_transit_non_transit_target_warehouse_validation(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
item_code = "_Test Transit Item 2"
|
||||
company = "_Test Company"
|
||||
|
||||
create_warehouse("Test Source Warehouse")
|
||||
create_warehouse("Test Regular Target Warehouse")
|
||||
|
||||
create_item(
|
||||
item_code=item_code,
|
||||
is_stock_item=1,
|
||||
is_purchase_item=1,
|
||||
company=company,
|
||||
)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target="Test Source Warehouse - _TC",
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
expense_account="Stock Adjustment - _TC",
|
||||
cost_center="Main - _TC",
|
||||
)
|
||||
|
||||
# Submitting or saving with add_to_transit=1 and a non-transit target warehouse must be rejected
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.purpose = "Material Transfer"
|
||||
se.stock_entry_type = "Material Transfer"
|
||||
se.company = company
|
||||
se.from_warehouse = "Test Source Warehouse - _TC"
|
||||
se.to_warehouse = "Test Regular Target Warehouse - _TC"
|
||||
se.add_to_transit = 1
|
||||
se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code,
|
||||
"s_warehouse": "Test Source Warehouse - _TC",
|
||||
"t_warehouse": "Test Regular Target Warehouse - _TC",
|
||||
"qty": 5,
|
||||
"basic_rate": 100,
|
||||
"expense_account": "Stock Adjustment - _TC",
|
||||
"cost_center": "Main - _TC",
|
||||
},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, se.save)
|
||||
|
||||
def test_material_receipt_gl_entry(self):
|
||||
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ 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
|
||||
@@ -488,65 +487,37 @@ 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:
|
||||
if entry.serial_no not in serial_nos_in_stock:
|
||||
continue
|
||||
total_current_qty += entry.qty
|
||||
entry.qty *= -1
|
||||
continue
|
||||
|
||||
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,
|
||||
)
|
||||
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: []}
|
||||
|
||||
@@ -977,19 +948,7 @@ class StockReconciliation(StockController):
|
||||
)
|
||||
)
|
||||
|
||||
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:
|
||||
def get_stranded_stock_value(self, row) -> 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
|
||||
@@ -998,10 +957,16 @@ 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_stock_value_difference
|
||||
from erpnext.stock.stock_ledger import get_previous_sle, get_stock_value_difference
|
||||
|
||||
if previous_sle is None:
|
||||
previous_sle = self.get_balance_before_reconciliation(row)
|
||||
previous_sle = get_previous_sle(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.warehouse,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
}
|
||||
)
|
||||
|
||||
if flt(previous_sle.get("qty_after_transaction")):
|
||||
return 0.0
|
||||
@@ -1011,23 +976,13 @@ class StockReconciliation(StockController):
|
||||
)
|
||||
|
||||
def make_adjustment_entry(self, row, sl_entries):
|
||||
previous_sle = self.get_balance_before_reconciliation(row)
|
||||
difference_amount = self.get_stranded_stock_value(row, previous_sle=previous_sle)
|
||||
difference_amount = self.get_stranded_stock_value(row)
|
||||
|
||||
# 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")):
|
||||
if not difference_amount:
|
||||
return
|
||||
|
||||
args = self.get_sle_for_items(row)
|
||||
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,
|
||||
}
|
||||
)
|
||||
args.update({"stock_value_difference": -1 * difference_amount, "is_adjustment_entry": 1})
|
||||
|
||||
sl_entries.append(args)
|
||||
|
||||
@@ -1090,16 +1045,7 @@ class StockReconciliation(StockController):
|
||||
has_dimensions = True
|
||||
|
||||
if self.docstatus == 2:
|
||||
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:
|
||||
if 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)
|
||||
@@ -1232,14 +1178,9 @@ 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)
|
||||
@@ -1269,50 +1210,6 @@ 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.
|
||||
|
||||
@@ -1327,14 +1224,6 @@ 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",
|
||||
{
|
||||
@@ -1347,12 +1236,12 @@ class StockReconciliation(StockController):
|
||||
as_dict=True,
|
||||
)
|
||||
if not reco_sle:
|
||||
return None
|
||||
return flt(row.current_qty, row.precision("current_qty"))
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
previous_sle = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.qty_after_transaction, sle.valuation_rate, sle.stock_value)
|
||||
.select(sle.qty_after_transaction)
|
||||
.where(
|
||||
(sle.item_code == row.item_code)
|
||||
& (sle.warehouse == row.warehouse)
|
||||
@@ -1368,9 +1257,9 @@ class StockReconciliation(StockController):
|
||||
.orderby(sle.posting_datetime, order=frappe.qb.desc)
|
||||
.orderby(sle.creation, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
).run(as_dict=True)
|
||||
).run()
|
||||
|
||||
return previous_sle[0] if previous_sle else frappe._dict()
|
||||
return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0
|
||||
|
||||
def submit(self):
|
||||
if len(self.items) > 100:
|
||||
|
||||
@@ -1324,39 +1324,6 @@ 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
|
||||
|
||||
@@ -2376,211 +2343,6 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
self.assertEqual(sles[0].qty_after_transaction, 0)
|
||||
self.assertEqual(flt(sles[0].stock_value_difference), -100.0)
|
||||
|
||||
def test_adjustment_entry_clears_value_stranded_at_zero_qty(self):
|
||||
"""bal_qty 0 with bal_val 500: the write-off has to bring the reported value to zero."""
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
from erpnext.stock.report.stock_balance.stock_balance import execute
|
||||
|
||||
item_code = self.make_item("Test Stock Reco Stranded Value Non Batch").name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(nowdate(), -3),
|
||||
)
|
||||
make_stock_entry(item_code=item_code, source=warehouse, qty=10, posting_date=add_days(nowdate(), -2))
|
||||
|
||||
# strand 500 of value: qty nets out, stock_value_difference does not
|
||||
receipt_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry", {"voucher_no": receipt.name, "is_cancelled": 0}, "name"
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
receipt_sle,
|
||||
"stock_value_difference",
|
||||
flt(frappe.db.get_value("Stock Ledger Entry", receipt_sle, "stock_value_difference")) + 500,
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
report_filters = frappe._dict(
|
||||
{"item_code": [item_code], "warehouse": [warehouse], "company": "_Test Company"}
|
||||
)
|
||||
|
||||
# this is what the user sees before the reconciliation
|
||||
_columns, data = execute(filters=report_filters)
|
||||
self.assertEqual(flt(data[0].get("bal_qty")), 0.0)
|
||||
self.assertEqual(flt(data[0].get("bal_val")), 500.0)
|
||||
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
|
||||
)
|
||||
sr.items[0].allow_zero_valuation_rate = 1
|
||||
sr.save()
|
||||
sr.submit()
|
||||
|
||||
sles = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": sr.name, "is_cancelled": 0},
|
||||
fields=["actual_qty", "qty_after_transaction", "stock_value_difference", "is_adjustment_entry"],
|
||||
)
|
||||
|
||||
self.assertEqual(len(sles), 1)
|
||||
self.assertEqual(sles[0].is_adjustment_entry, 1)
|
||||
self.assertEqual(flt(sles[0].actual_qty), 0.0)
|
||||
self.assertEqual(flt(sles[0].qty_after_transaction), 0.0)
|
||||
self.assertEqual(flt(sles[0].stock_value_difference), -500.0)
|
||||
|
||||
# the report, and the GL basis behind it, both land on zero
|
||||
# (the row drops out entirely once every figure on it is zero)
|
||||
_columns, data = execute(filters=report_filters)
|
||||
self.assertEqual(flt(data[0].get("bal_val")) if data else 0.0, 0.0)
|
||||
self.assertEqual(
|
||||
flt(get_stock_value_on(warehouses=warehouse, posting_date=nowdate(), item_code=item_code)),
|
||||
0.0,
|
||||
)
|
||||
|
||||
def _make_backdated_adjustment_scenario(self, item_name, valuation_method, backdated_qty=4):
|
||||
"""Strand 100 of value at zero qty, write it off, then backdate a receipt before the write-off."""
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
item_code = self.make_item(item_name, {"valuation_method": valuation_method}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt = make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(nowdate(), -10),
|
||||
)
|
||||
make_stock_entry(item_code=item_code, source=warehouse, qty=10, posting_date=add_days(nowdate(), -9))
|
||||
|
||||
# strand 100 of value on the ledger: qty nets out, stock_value_difference does not
|
||||
receipt_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry", {"voucher_no": receipt.name, "is_cancelled": 0}, "name"
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
receipt_sle,
|
||||
"stock_value_difference",
|
||||
flt(frappe.db.get_value("Stock Ledger Entry", receipt_sle, "stock_value_difference")) + 100,
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=0,
|
||||
rate=0,
|
||||
posting_date=add_days(nowdate(), -5),
|
||||
do_not_save=1,
|
||||
)
|
||||
sr.items[0].allow_zero_valuation_rate = 1
|
||||
sr.save()
|
||||
sr.submit()
|
||||
|
||||
self.assertTrue(
|
||||
frappe.db.exists("Stock Ledger Entry", {"voucher_no": sr.name, "is_adjustment_entry": 1})
|
||||
)
|
||||
|
||||
# a backdated receipt lands before the write-off
|
||||
if backdated_qty:
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=backdated_qty,
|
||||
basic_rate=50,
|
||||
posting_date=add_days(nowdate(), -7),
|
||||
)
|
||||
|
||||
return item_code, warehouse, sr
|
||||
|
||||
def _assert_backdated_stock_survives(self, item_code, warehouse, sr):
|
||||
adjustment_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": sr.name, "is_cancelled": 0},
|
||||
["qty_after_transaction", "stock_value", "stock_value_difference", "valuation_rate"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
# the backdated stock is carried through the adjustment entry, not wiped out by it
|
||||
self.assertEqual(flt(adjustment_sle.qty_after_transaction), 4.0)
|
||||
self.assertEqual(flt(adjustment_sle.stock_value), 200.0)
|
||||
self.assertEqual(flt(adjustment_sle.valuation_rate), 50.0)
|
||||
|
||||
# and the write-off still lands the running ledger value on the stock value it holds
|
||||
self.assertEqual(
|
||||
flt(get_stock_value_on(warehouses=warehouse, posting_date=nowdate(), item_code=item_code)),
|
||||
200.0,
|
||||
)
|
||||
self.assertEqual(get_stock_balance(item_code, warehouse), 4.0)
|
||||
|
||||
def test_adjustment_entry_does_not_zero_out_backdated_stock(self):
|
||||
"""An adjustment entry restates value, so a backdated receipt posted before it must survive."""
|
||||
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
|
||||
"Test Stock Reco Backdated Adjustment", "FIFO"
|
||||
)
|
||||
self._assert_backdated_stock_survives(item_code, warehouse, sr)
|
||||
|
||||
def test_adjustment_entry_does_not_zero_out_backdated_stock_moving_average(self):
|
||||
"""Same, through the moving average path rather than the queue."""
|
||||
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
|
||||
"Test Stock Reco Backdated Adjustment MA", "Moving Average"
|
||||
)
|
||||
self._assert_backdated_stock_survives(item_code, warehouse, sr)
|
||||
|
||||
def test_adjustment_row_amount_is_not_distorted_by_rate_rounding(self):
|
||||
"""The refreshed amount comes from the ledger's stock value, not from a rounded rate."""
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
|
||||
"Test Stock Reco Adjustment Rounding", "FIFO", backdated_qty=0
|
||||
)
|
||||
|
||||
# a backdated receipt whose value does not divide evenly into a 2 decimal rate
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=10000,
|
||||
basic_rate=1.2345,
|
||||
posting_date=add_days(nowdate(), -7),
|
||||
)
|
||||
|
||||
sr.reload()
|
||||
row = sr.items[0]
|
||||
|
||||
self.assertEqual(flt(row.current_qty), 10000.0)
|
||||
self.assertEqual(flt(row.current_amount), 12345.0)
|
||||
|
||||
def test_cancelling_adjustment_entry_shifts_no_qty(self):
|
||||
"""Reversing a value-only entry must not push the preserved quantity into later entries."""
|
||||
from erpnext.stock.stock_ledger import get_stock_reco_qty_shift
|
||||
|
||||
_item_code, _warehouse, sr = self._make_backdated_adjustment_scenario(
|
||||
"Test Stock Reco Adjustment Cancel", "FIFO"
|
||||
)
|
||||
|
||||
sr.reload()
|
||||
row = sr.items[0]
|
||||
|
||||
# the refreshed document reports the balance the ledger carries and no quantity movement
|
||||
self.assertEqual(flt(row.current_qty), 4.0)
|
||||
self.assertEqual(flt(row.quantity_difference), 0.0)
|
||||
self.assertEqual(flt(row.current_valuation_rate), 50.0)
|
||||
self.assertEqual(flt(row.current_amount), 200.0)
|
||||
self.assertEqual(flt(row.amount_difference), -100.0)
|
||||
|
||||
# the reversal built on cancellation moves nothing, so later entries are not shifted
|
||||
sr.docstatus = 2
|
||||
args = sr.get_sle_for_items(row)
|
||||
args.actual_qty = -flt(args.actual_qty) # as make_sl_entries flips it for a cancellation
|
||||
|
||||
self.assertEqual(flt(args.actual_qty), 0.0)
|
||||
self.assertEqual(flt(get_stock_reco_qty_shift(args)), 0.0)
|
||||
|
||||
|
||||
def create_batch_item_with_batch(item_name, batch_id):
|
||||
batch_item_doc = create_item(item_name, is_stock_item=1)
|
||||
|
||||
@@ -109,63 +109,30 @@ class SerialBatchBundle:
|
||||
):
|
||||
return True
|
||||
|
||||
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
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
def make_serial_batch_no_bundle_for_material_transfer(self, bundle):
|
||||
def make_serial_batch_no_bundle_for_material_transfer(self):
|
||||
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,
|
||||
bundle = frappe.db.get_value(
|
||||
"Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle"
|
||||
)
|
||||
self.sle.db_set({"serial_and_batch_bundle": new_bundle_id})
|
||||
|
||||
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(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(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle"
|
||||
)
|
||||
)
|
||||
self.make_serial_batch_no_bundle_for_material_transfer()
|
||||
return
|
||||
|
||||
sn_doc = SerialBatchCreation(
|
||||
@@ -346,7 +313,6 @@ 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()
|
||||
|
||||
@@ -580,14 +580,7 @@ class SerialBatchBundleService:
|
||||
)
|
||||
|
||||
def make_package_for_transfer(
|
||||
self,
|
||||
serial_and_batch_bundle,
|
||||
warehouse,
|
||||
type_of_transaction=None,
|
||||
do_not_submit=None,
|
||||
qty=0,
|
||||
include_bundle=None,
|
||||
exclude_serial_nos=None,
|
||||
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
|
||||
):
|
||||
from erpnext.controllers.stock_controller import make_bundle_for_material_transfer
|
||||
|
||||
@@ -601,8 +594,6 @@ class SerialBatchBundleService:
|
||||
type_of_transaction=type_of_transaction,
|
||||
do_not_submit=do_not_submit,
|
||||
qty=qty,
|
||||
include_bundle=include_bundle,
|
||||
exclude_serial_nos=exclude_serial_nos,
|
||||
)
|
||||
|
||||
def validate_reserved_batches(self):
|
||||
|
||||
@@ -28,6 +28,13 @@ 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,
|
||||
@@ -35,7 +42,6 @@ from erpnext.stock.utils import (
|
||||
get_serial_nos_data,
|
||||
get_stock_balance,
|
||||
get_valuation_method,
|
||||
is_serial_no_wise_valuation_disabled,
|
||||
)
|
||||
from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero
|
||||
|
||||
@@ -640,7 +646,6 @@ class update_entries_after:
|
||||
self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company")
|
||||
self.set_precision()
|
||||
self.valuation_method = get_valuation_method(self.item_code, self.company)
|
||||
self.skip_serial_batch_valuation = is_serial_no_wise_valuation_disabled(self.item_code)
|
||||
self.repost_affected_transaction = args.get("repost_affected_transaction") or set()
|
||||
|
||||
self.new_items_found = False
|
||||
@@ -954,10 +959,8 @@ 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"):
|
||||
# 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)
|
||||
if self.args.get("cancelled") and sl_entries:
|
||||
self.seed_previous_sle_for_cancellation(sl_entries[0])
|
||||
for sle in sl_entries:
|
||||
sle["timestamp"] = sle.posting_datetime
|
||||
self.process_sle(sle)
|
||||
@@ -968,7 +971,7 @@ class update_entries_after:
|
||||
return
|
||||
|
||||
args = frappe._dict(anchor_sle)
|
||||
args["sle_id"] = args.get("name")
|
||||
args["sle_id"] = args.name
|
||||
prev_sle = get_previous_sle_of_current_voucher(args)
|
||||
if prev_sle:
|
||||
self.prev_sle_dict[key] = prev_sle
|
||||
@@ -1086,9 +1089,9 @@ class update_entries_after:
|
||||
# Inventory is always carried at the standard rate effective on the posting date;
|
||||
# FIFO/Moving Average/serial-batch valuation is bypassed entirely.
|
||||
self.process_standard_cost(sle)
|
||||
elif sle.serial_and_batch_bundle and not self.skip_serial_batch_valuation:
|
||||
elif sle.serial_and_batch_bundle:
|
||||
self.calculate_valuation_for_serial_batch_bundle(sle)
|
||||
elif sle.serial_no and not self.skip_serial_batch_valuation and not self.args.get("sle_id"):
|
||||
elif sle.serial_no and not self.args.get("sle_id"):
|
||||
# Only run in reposting
|
||||
self.get_serialized_values(sle)
|
||||
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
|
||||
@@ -1100,7 +1103,6 @@ class update_entries_after:
|
||||
)
|
||||
elif (
|
||||
sle.batch_no
|
||||
and not self.skip_serial_batch_valuation
|
||||
and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True)
|
||||
and not self.args.get("sle_id")
|
||||
):
|
||||
@@ -1109,8 +1111,6 @@ 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,20 +1172,25 @@ class update_entries_after:
|
||||
|
||||
sle.stock_value_difference = stock_value_difference
|
||||
|
||||
# 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,
|
||||
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
|
||||
)
|
||||
|
||||
sle.stock_value_difference = flt(flt(sle.stock_value) - value_till_now, self.currency_precision)
|
||||
|
||||
sle.doctype = "Stock Ledger Entry"
|
||||
sle.modified = now()
|
||||
@@ -1414,19 +1419,6 @@ class update_entries_after:
|
||||
else:
|
||||
sle.outgoing_rate = rate
|
||||
|
||||
elif self.has_stale_serial_no_wise_outgoing_rate(sle):
|
||||
# Serial No Wise Valuation is off, but the entry still carries its serial nos' rate and has
|
||||
# no recalculate_rate flag to re-derive it. Value it at the rate running just before it.
|
||||
sle.outgoing_rate = flt(self.wh_data.valuation_rate)
|
||||
|
||||
def has_stale_serial_no_wise_outgoing_rate(self, sle):
|
||||
return bool(
|
||||
self.skip_serial_batch_valuation
|
||||
and self.valuation_method == "Moving Average"
|
||||
and flt(sle.actual_qty) < 0
|
||||
and flt(sle.outgoing_rate)
|
||||
)
|
||||
|
||||
def has_landed_cost_based_on_pi(self, sle):
|
||||
if sle.voucher_type == "Purchase Receipt" and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
@@ -1454,9 +1446,11 @@ class update_entries_after:
|
||||
get_rate_for_return, # don't move this import to top
|
||||
)
|
||||
|
||||
if self.valuation_method == "Moving Average" and (
|
||||
self.skip_serial_batch_valuation
|
||||
or not (sle.get("serial_no") or sle.get("batch_no") or sle.get("serial_and_batch_bundle"))
|
||||
if (
|
||||
self.valuation_method == "Moving Average"
|
||||
and not sle.get("serial_no")
|
||||
and not sle.get("batch_no")
|
||||
and not sle.get("serial_and_batch_bundle")
|
||||
):
|
||||
rate = self.get_moving_average_rate_for_return(sle)
|
||||
|
||||
@@ -1469,9 +1463,6 @@ class update_entries_after:
|
||||
sle=sle,
|
||||
)
|
||||
|
||||
elif self.skip_serial_batch_valuation and flt(sle.actual_qty) < 0:
|
||||
rate = 0.0
|
||||
|
||||
else:
|
||||
rate = get_rate_for_return(
|
||||
sle.voucher_type,
|
||||
@@ -1813,9 +1804,6 @@ class update_entries_after:
|
||||
self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction
|
||||
|
||||
def is_return_purchase_entry(self, sle):
|
||||
if self.skip_serial_batch_valuation:
|
||||
return False
|
||||
|
||||
if sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"]:
|
||||
return frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_return")
|
||||
|
||||
@@ -2599,6 +2587,51 @@ 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
|
||||
|
||||
@@ -317,26 +317,15 @@ def _get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fa
|
||||
in_rate = None
|
||||
|
||||
item_details = frappe.get_cached_value(
|
||||
"Item",
|
||||
args.get("item_code"),
|
||||
["has_serial_no", "has_batch_no", "use_serial_no_wise_valuation"],
|
||||
as_dict=1,
|
||||
"Item", args.get("item_code"), ["has_serial_no", "has_batch_no"], as_dict=1
|
||||
)
|
||||
|
||||
use_moving_avg_for_batch = frappe.get_single_value("Stock Settings", "do_not_use_batchwise_valuation")
|
||||
skip_serial_batch_valuation = bool(
|
||||
item_details and item_details.has_serial_no and not item_details.use_serial_no_wise_valuation
|
||||
)
|
||||
|
||||
if isinstance(args, dict):
|
||||
args = frappe._dict(args)
|
||||
|
||||
if (
|
||||
item_details
|
||||
and item_details.has_serial_no
|
||||
and args.get("serial_and_batch_bundle")
|
||||
and not skip_serial_batch_valuation
|
||||
):
|
||||
if item_details and item_details.has_serial_no and args.get("serial_and_batch_bundle"):
|
||||
args.actual_qty = args.qty
|
||||
sn_obj = SerialNoValuation(
|
||||
sle=args,
|
||||
@@ -351,7 +340,6 @@ def _get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fa
|
||||
and item_details.has_batch_no
|
||||
and args.get("serial_and_batch_bundle")
|
||||
and not use_moving_avg_for_batch
|
||||
and not skip_serial_batch_valuation
|
||||
):
|
||||
args.actual_qty = args.qty
|
||||
batch_obj = BatchNoValuation(
|
||||
@@ -362,23 +350,14 @@ def _get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fa
|
||||
|
||||
return batch_obj.get_incoming_rate()
|
||||
|
||||
elif (
|
||||
(args.get("serial_no") or "").strip()
|
||||
and not args.get("serial_and_batch_bundle")
|
||||
and not skip_serial_batch_valuation
|
||||
):
|
||||
elif (args.get("serial_no") or "").strip() and not args.get("serial_and_batch_bundle"):
|
||||
args.actual_qty = args.qty
|
||||
args.serial_nos = get_serial_nos_data(args.get("serial_no"))
|
||||
|
||||
sn_obj = SerialNoValuation(sle=args, warehouse=args.get("warehouse"), item_code=args.get("item_code"))
|
||||
|
||||
return sn_obj.get_incoming_rate()
|
||||
elif (
|
||||
args.get("batch_no")
|
||||
and not args.get("serial_and_batch_bundle")
|
||||
and not use_moving_avg_for_batch
|
||||
and not skip_serial_batch_valuation
|
||||
):
|
||||
elif args.get("batch_no") and not args.get("serial_and_batch_bundle") and not use_moving_avg_for_batch:
|
||||
args.actual_qty = args.qty
|
||||
args.batch_nos = frappe._dict({args.batch_no: args})
|
||||
|
||||
@@ -431,16 +410,9 @@ def get_avg_purchase_rate(serial_nos):
|
||||
)
|
||||
|
||||
|
||||
def is_serial_no_wise_valuation_disabled(item_code) -> bool:
|
||||
item_details = frappe.get_cached_value(
|
||||
"Item", item_code, ["has_serial_no", "use_serial_no_wise_valuation"], as_dict=1
|
||||
)
|
||||
|
||||
return bool(item_details and item_details.has_serial_no and not item_details.use_serial_no_wise_valuation)
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_valuation_method(item_code, company=None):
|
||||
"""get valuation method from item or default"""
|
||||
val_method = frappe.get_cached_value("Item", item_code, "valuation_method")
|
||||
if not val_method:
|
||||
val_method = (
|
||||
@@ -451,14 +423,6 @@ def get_valuation_method(item_code, company=None):
|
||||
return val_method
|
||||
|
||||
|
||||
def clear_valuation_method_cache():
|
||||
cache = getattr(frappe.local, "request_cache", None)
|
||||
if not cache:
|
||||
return
|
||||
|
||||
cache.pop(getattr(get_valuation_method, "__wrapped__", get_valuation_method), None)
|
||||
|
||||
|
||||
def get_fifo_rate(previous_stock_queue, qty):
|
||||
"""get FIFO (average) Rate from Queue"""
|
||||
return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO")
|
||||
|
||||
Reference in New Issue
Block a user