mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-05 05:09:11 +00:00
Merge branch 'version-13-hotfix' of https://github.com/frappe/erpnext into pre_release
This commit is contained in:
@@ -757,6 +757,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fetch_from": "item_code.grant_commission",
|
||||
"fieldname": "grant_commission",
|
||||
"fieldtype": "Check",
|
||||
"label": "Grant Commission",
|
||||
@@ -767,12 +768,14 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-10-06 12:12:44.018872",
|
||||
"modified": "2022-02-24 14:42:20.211085",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Note Item",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC"
|
||||
}
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -594,7 +594,7 @@ $.extend(erpnext.item, {
|
||||
const increment = r.message.increment;
|
||||
|
||||
let values = [];
|
||||
for(var i = from; i <= to; i += increment) {
|
||||
for(var i = from; i <= to; i = flt(i + increment, 6)) {
|
||||
values.push(i);
|
||||
}
|
||||
attr_val_fields[d.attribute] = values;
|
||||
|
||||
@@ -399,6 +399,7 @@ class Item(Document):
|
||||
|
||||
if merge:
|
||||
self.validate_properties_before_merge(new_name)
|
||||
self.validate_duplicate_product_bundles_before_merge(old_name, new_name)
|
||||
self.validate_duplicate_website_item_before_merge(old_name, new_name)
|
||||
|
||||
def after_rename(self, old_name, new_name, merge):
|
||||
@@ -463,6 +464,20 @@ class Item(Document):
|
||||
msg += ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list])
|
||||
frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError)
|
||||
|
||||
def validate_duplicate_product_bundles_before_merge(self, old_name, new_name):
|
||||
"Block merge if both old and new items have product bundles."
|
||||
old_bundle = frappe.get_value("Product Bundle",filters={"new_item_code": old_name})
|
||||
new_bundle = frappe.get_value("Product Bundle",filters={"new_item_code": new_name})
|
||||
|
||||
if old_bundle and new_bundle:
|
||||
bundle_link = get_link_to_form("Product Bundle", old_bundle)
|
||||
old_name, new_name = frappe.bold(old_name), frappe.bold(new_name)
|
||||
|
||||
msg = _("Please delete Product Bundle {0}, before merging {1} into {2}").format(
|
||||
bundle_link, old_name, new_name
|
||||
)
|
||||
frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError)
|
||||
|
||||
def validate_duplicate_website_item_before_merge(self, old_name, new_name):
|
||||
"""
|
||||
Block merge if both old and new items have website items against them.
|
||||
@@ -480,8 +495,9 @@ class Item(Document):
|
||||
|
||||
old_web_item = [d.get("name") for d in web_items if d.get("item_code") == old_name][0]
|
||||
web_item_link = get_link_to_form("Website Item", old_web_item)
|
||||
old_name, new_name = frappe.bold(old_name), frappe.bold(new_name)
|
||||
|
||||
msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} and {new_name}"
|
||||
msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} into {new_name}"
|
||||
frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError)
|
||||
|
||||
def set_last_purchase_rate(self, new_name):
|
||||
|
||||
@@ -14,6 +14,7 @@ from erpnext.controllers.item_variant import (
|
||||
get_variant,
|
||||
)
|
||||
from erpnext.stock.doctype.item.item import (
|
||||
DataValidationError,
|
||||
InvalidBarcode,
|
||||
StockExistsForTemplate,
|
||||
get_item_attribute,
|
||||
@@ -387,6 +388,26 @@ class TestItem(ERPNextTestCase):
|
||||
self.assertTrue(frappe.db.get_value("Bin",
|
||||
{"item_code": "Test Item for Merging 2", "warehouse": "_Test Warehouse 1 - _TC"}))
|
||||
|
||||
def test_item_merging_with_product_bundle(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
|
||||
create_item("Test Item Bundle Item 1", is_stock_item=False)
|
||||
create_item("Test Item Bundle Item 2", is_stock_item=False)
|
||||
create_item("Test Item inside Bundle")
|
||||
bundle_items = ["Test Item inside Bundle"]
|
||||
|
||||
# make bundles for both items
|
||||
bundle1 = make_product_bundle("Test Item Bundle Item 1", bundle_items, qty=2)
|
||||
make_product_bundle("Test Item Bundle Item 2", bundle_items, qty=2)
|
||||
|
||||
with self.assertRaises(DataValidationError):
|
||||
frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
|
||||
|
||||
bundle1.delete()
|
||||
frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item", "Test Item Bundle Item 1"))
|
||||
|
||||
def test_uom_conversion_factor(self):
|
||||
if frappe.db.exists('Item', 'Test Item UOM'):
|
||||
frappe.delete_doc('Item', 'Test Item UOM')
|
||||
|
||||
@@ -10,6 +10,7 @@ from erpnext.accounts.doctype.account.test_account import create_account, get_in
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.utils import update_gl_entries_after
|
||||
from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
get_gl_entries,
|
||||
make_purchase_receipt,
|
||||
@@ -177,6 +178,53 @@ class TestLandedCostVoucher(ERPNextTestCase):
|
||||
self.assertEqual(serial_no.purchase_rate - serial_no_rate, 5.0)
|
||||
self.assertEqual(serial_no.warehouse, "Stores - TCP1")
|
||||
|
||||
def test_serialized_lcv_delivered(self):
|
||||
"""In some cases you'd want to deliver before you can know all the
|
||||
landed costs, this should be allowed for serial nos too.
|
||||
|
||||
Case:
|
||||
- receipt a serial no @ X rate
|
||||
- delivery the serial no @ X rate
|
||||
- add LCV to receipt X + Y
|
||||
- LCV should be successful
|
||||
- delivery should reflect X+Y valuation.
|
||||
"""
|
||||
serial_no = "LCV_TEST_SR_NO"
|
||||
item_code = "_Test Serialized Item"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
pr = make_purchase_receipt(company="_Test Company with perpetual inventory",
|
||||
warehouse=warehouse, qty=1, rate=200,
|
||||
item_code=item_code, serial_no=serial_no)
|
||||
|
||||
serial_no_rate = frappe.db.get_value("Serial No", serial_no, "purchase_rate")
|
||||
|
||||
# deliver it before creating LCV
|
||||
dn = create_delivery_note(item_code=item_code,
|
||||
company='_Test Company with perpetual inventory', warehouse='Stores - TCP1',
|
||||
serial_no=serial_no, qty=1, rate=500,
|
||||
cost_center = 'Main - TCP1', expense_account = "Cost of Goods Sold - TCP1")
|
||||
|
||||
charges = 10
|
||||
create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=charges)
|
||||
|
||||
new_purchase_rate = serial_no_rate + charges
|
||||
|
||||
serial_no = frappe.db.get_value("Serial No", serial_no,
|
||||
["warehouse", "purchase_rate"], as_dict=1)
|
||||
|
||||
self.assertEqual(serial_no.purchase_rate, new_purchase_rate)
|
||||
|
||||
stock_value_difference = frappe.db.get_value("Stock Ledger Entry",
|
||||
filters={
|
||||
"voucher_no": dn.name,
|
||||
"voucher_type": dn.doctype,
|
||||
"is_cancelled": 0 # LCV cancels with same name.
|
||||
},
|
||||
fieldname="stock_value_difference")
|
||||
|
||||
# reposting should update the purchase rate in future delivery
|
||||
self.assertEqual(stock_value_difference, -new_purchase_rate)
|
||||
|
||||
def test_landed_cost_voucher_for_odd_numbers (self):
|
||||
pr = make_purchase_receipt(company="_Test Company with perpetual inventory", warehouse = "Stores - TCP1", supplier_warehouse = "Work in Progress - TCP1", do_not_save=True)
|
||||
|
||||
@@ -57,14 +57,13 @@ class MaterialRequest(BuyingController):
|
||||
if actual_so_qty and (flt(so_items[so_no][item]) + already_indented > actual_so_qty):
|
||||
frappe.throw(_("Material Request of maximum {0} can be made for Item {1} against Sales Order {2}").format(actual_so_qty - already_indented, item, so_no))
|
||||
|
||||
# Validate
|
||||
# ---------------------
|
||||
def validate(self):
|
||||
super(MaterialRequest, self).validate()
|
||||
|
||||
self.validate_schedule_date()
|
||||
self.check_for_on_hold_or_closed_status('Sales Order', 'sales_order')
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_material_request_type()
|
||||
|
||||
if not self.status:
|
||||
self.status = "Draft"
|
||||
@@ -84,6 +83,12 @@ class MaterialRequest(BuyingController):
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
|
||||
|
||||
def validate_material_request_type(self):
|
||||
""" Validate fields in accordance with selected type """
|
||||
|
||||
if self.material_request_type != "Customer Provided":
|
||||
self.customer = None
|
||||
|
||||
def set_title(self):
|
||||
'''Set title as comma separated list of items'''
|
||||
if not self.title:
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"section_break_13",
|
||||
"actual_qty",
|
||||
"projected_qty",
|
||||
"ordered_qty",
|
||||
"column_break_16",
|
||||
"incoming_rate",
|
||||
"page_break",
|
||||
@@ -224,13 +225,21 @@
|
||||
"label": "Rate",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "ordered_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Ordered Qty",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-01-28 16:03:30.780111",
|
||||
"modified": "2022-02-22 12:57:45.325488",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Packed Item",
|
||||
|
||||
@@ -162,6 +162,15 @@ class TestPurchaseReceipt(ERPNextTestCase):
|
||||
qty=abs(existing_bin_qty)
|
||||
)
|
||||
|
||||
existing_bin_qty, existing_bin_stock_value = frappe.db.get_value(
|
||||
"Bin",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"warehouse": "_Test Warehouse - _TC"
|
||||
},
|
||||
["actual_qty", "stock_value"]
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt()
|
||||
|
||||
stock_value_difference = frappe.db.get_value(
|
||||
|
||||
@@ -9,8 +9,7 @@ from collections import defaultdict
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, floor, flt, nowdate
|
||||
from six import string_types
|
||||
from frappe.utils import cint, cstr, floor, flt, nowdate
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.utils import get_stock_balance
|
||||
@@ -75,7 +74,7 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
|
||||
purpose: Purpose of Stock Entry
|
||||
sync (optional): Sync with client side only for client side calls
|
||||
"""
|
||||
if isinstance(items, string_types):
|
||||
if isinstance(items, str):
|
||||
items = json.loads(items)
|
||||
|
||||
items_not_accomodated, updated_table = [], []
|
||||
@@ -143,11 +142,44 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
|
||||
if items_not_accomodated:
|
||||
show_unassigned_items_message(items_not_accomodated)
|
||||
|
||||
items[:] = updated_table if updated_table else items # modify items table
|
||||
if updated_table and _items_changed(items, updated_table, doctype):
|
||||
items[:] = updated_table
|
||||
frappe.msgprint(_("Applied putaway rules."), alert=True)
|
||||
|
||||
if sync and json.loads(sync): # sync with client side
|
||||
return items
|
||||
|
||||
def _items_changed(old, new, doctype: str) -> bool:
|
||||
""" Check if any items changed by application of putaway rules.
|
||||
|
||||
If not, changing item table can have side effects since `name` items also changes.
|
||||
"""
|
||||
if len(old) != len(new):
|
||||
return True
|
||||
|
||||
old = [frappe._dict(item) if isinstance(item, dict) else item for item in old]
|
||||
|
||||
if doctype == "Stock Entry":
|
||||
compare_keys = ("item_code", "t_warehouse", "transfer_qty", "serial_no")
|
||||
sort_key = lambda item: (item.item_code, cstr(item.t_warehouse), # noqa
|
||||
flt(item.transfer_qty), cstr(item.serial_no))
|
||||
else:
|
||||
# purchase receipt / invoice
|
||||
compare_keys = ("item_code", "warehouse", "stock_qty", "received_qty", "serial_no")
|
||||
sort_key = lambda item: (item.item_code, cstr(item.warehouse), # noqa
|
||||
flt(item.stock_qty), flt(item.received_qty), cstr(item.serial_no))
|
||||
|
||||
old_sorted = sorted(old, key=sort_key)
|
||||
new_sorted = sorted(new, key=sort_key)
|
||||
|
||||
# Once sorted by all relevant keys both tables should align if they are same.
|
||||
for old_item, new_item in zip(old_sorted, new_sorted):
|
||||
for key in compare_keys:
|
||||
if old_item.get(key) != new_item.get(key):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_ordered_putaway_rules(item_code, company, source_warehouse=None):
|
||||
"""Returns an ordered list of putaway rules to apply on an item."""
|
||||
filters = {
|
||||
|
||||
@@ -35,6 +35,18 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
new_uom.uom_name = "Bag"
|
||||
new_uom.save()
|
||||
|
||||
def assertUnchangedItemsOnResave(self, doc):
|
||||
""" Check if same items remain even after reapplication of rules.
|
||||
|
||||
This is required since some business logic like subcontracting
|
||||
depends on `name` of items to be same if item isn't changed.
|
||||
"""
|
||||
doc.reload()
|
||||
old_items = {d.name for d in doc.items}
|
||||
doc.save()
|
||||
new_items = {d.name for d in doc.items}
|
||||
self.assertSetEqual(old_items, new_items)
|
||||
|
||||
def test_putaway_rules_priority(self):
|
||||
"""Test if rule is applied by priority, irrespective of free space."""
|
||||
rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200,
|
||||
@@ -50,6 +62,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(pr.items[1].qty, 100)
|
||||
self.assertEqual(pr.items[1].warehouse, self.warehouse_2)
|
||||
|
||||
self.assertUnchangedItemsOnResave(pr)
|
||||
|
||||
pr.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -162,6 +176,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
# leftover space was for 500 kg (0.5 Bag)
|
||||
# Since Bag is a whole UOM, 1(out of 2) Bag will be unassigned
|
||||
|
||||
self.assertUnchangedItemsOnResave(pr)
|
||||
|
||||
pr.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -196,6 +212,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(pr.items[1].warehouse, self.warehouse_1)
|
||||
self.assertEqual(pr.items[1].putaway_rule, rule_1.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(pr)
|
||||
|
||||
pr.delete()
|
||||
rule_1.delete()
|
||||
|
||||
@@ -239,6 +257,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry_item.qty, 100) # unassigned 100 out of 200 Kg
|
||||
self.assertEqual(stock_entry_item.putaway_rule, rule_2.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -294,6 +314,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry.items[2].qty, 200)
|
||||
self.assertEqual(stock_entry.items[2].putaway_rule, rule_2.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -344,6 +366,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry.items[1].serial_no, "\n".join(serial_nos[3:]))
|
||||
self.assertEqual(stock_entry.items[1].batch_no, "BOTTL-BATCH-1")
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
pr.cancel()
|
||||
rule_1.delete()
|
||||
@@ -366,6 +390,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry_item.qty, 100)
|
||||
self.assertEqual(stock_entry_item.putaway_rule, rule_1.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
|
||||
@@ -627,6 +627,12 @@ frappe.ui.form.on('Stock Entry Detail', {
|
||||
frm.events.set_serial_no(frm, cdt, cdn, () => {
|
||||
frm.events.get_warehouse_details(frm, cdt, cdn);
|
||||
});
|
||||
|
||||
// set allow_zero_valuation_rate to 0 if s_warehouse is selected.
|
||||
let item = frappe.get_doc(cdt, cdn);
|
||||
if (item.s_warehouse) {
|
||||
item.allow_zero_valuation_rate = 0;
|
||||
}
|
||||
},
|
||||
|
||||
t_warehouse: function(frm, cdt, cdn) {
|
||||
|
||||
@@ -45,6 +45,7 @@ def get_sle(**args):
|
||||
|
||||
class TestStockEntry(ERPNextTestCase):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0")
|
||||
|
||||
@@ -566,6 +567,7 @@ class TestStockEntry(ERPNextTestCase):
|
||||
st1.set_stock_entry_type()
|
||||
st1.insert()
|
||||
st1.submit()
|
||||
st1.cancel()
|
||||
|
||||
frappe.set_user("Administrator")
|
||||
remove_user_permission("Warehouse", "_Test Warehouse 1 - _TC", "test@example.com")
|
||||
@@ -690,6 +692,8 @@ class TestStockEntry(ERPNextTestCase):
|
||||
bom_no = frappe.db.get_value("BOM", {"item": "_Test Variant Item",
|
||||
"is_default": 1, "docstatus": 1})
|
||||
|
||||
make_item_variant() # make variant of _Test Variant Item if absent
|
||||
|
||||
work_order = frappe.new_doc("Work Order")
|
||||
work_order.update({
|
||||
"company": "_Test Company",
|
||||
@@ -1101,13 +1105,10 @@ class TestStockEntry(ERPNextTestCase):
|
||||
|
||||
# Check if FG cost is calculated based on RM total cost
|
||||
# RM total cost = 200, FG rate = 200/4(FG qty) = 50
|
||||
self.assertEqual(se.items[1].basic_rate, 50)
|
||||
self.assertEqual(se.items[1].basic_rate, flt(se.items[0].basic_rate/4))
|
||||
self.assertEqual(se.value_difference, 0.0)
|
||||
self.assertEqual(se.total_incoming_value, se.total_outgoing_value)
|
||||
|
||||
# teardown
|
||||
se.delete()
|
||||
|
||||
def make_serialized_item(**args):
|
||||
args = frappe._dict(args)
|
||||
se = frappe.copy_doc(test_records[0])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "hash",
|
||||
"creation": "2013-03-29 18:22:12",
|
||||
"creation": "2022-02-05 00:17:49.860824",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Other",
|
||||
"editable_grid": 1,
|
||||
@@ -340,13 +340,13 @@
|
||||
"label": "More Information"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "0",
|
||||
"fieldname": "allow_zero_valuation_rate",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Zero Valuation Rate",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1
|
||||
"print_hide": 1,
|
||||
"read_only_depends_on": "eval:doc.s_warehouse"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
@@ -556,12 +556,14 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-06-22 16:47:11.268975",
|
||||
"modified": "2022-02-26 00:51:24.963653",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry Detail",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "ASC"
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.core.page.permission_manager.permission_manager import reset
|
||||
from frappe.utils import add_days, today
|
||||
@@ -32,6 +34,27 @@ class TestStockLedgerEntry(ERPNextTestCase):
|
||||
frappe.db.sql("delete from `tabStock Ledger Entry` where item_code in (%s)" % (', '.join(['%s']*len(items))), items)
|
||||
frappe.db.sql("delete from `tabBin` where item_code in (%s)" % (', '.join(['%s']*len(items))), items)
|
||||
|
||||
|
||||
def assertSLEs(self, doc, expected_sles, sle_filters=None):
|
||||
""" Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line"""
|
||||
|
||||
filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0}
|
||||
if sle_filters:
|
||||
filters.update(sle_filters)
|
||||
sles = frappe.get_all("Stock Ledger Entry", fields=["*"], filters=filters,
|
||||
order_by="timestamp(posting_date, posting_time), creation")
|
||||
|
||||
for exp_sle, act_sle in zip(expected_sles, sles):
|
||||
for k, v in exp_sle.items():
|
||||
act_value = act_sle[k]
|
||||
if k == "stock_queue":
|
||||
act_value = json.loads(act_value)
|
||||
if act_value and act_value[0][0] == 0:
|
||||
# ignore empty fifo bins
|
||||
continue
|
||||
|
||||
self.assertEqual(v, act_value, msg=f"{k} doesn't match \n{exp_sle}\n{act_sle}")
|
||||
|
||||
def test_item_cost_reposting(self):
|
||||
company = "_Test Company"
|
||||
|
||||
@@ -349,6 +372,77 @@ class TestStockLedgerEntry(ERPNextTestCase):
|
||||
frappe.set_user("Administrator")
|
||||
user.remove_roles("Stock Manager")
|
||||
|
||||
def test_fifo_dependent_consumption(self):
|
||||
item = make_item("_TestFifoTransferRates")
|
||||
source = "_Test Warehouse - _TC"
|
||||
target = "Stores - _TC"
|
||||
|
||||
rates = [10 * i for i in range(1, 20)]
|
||||
|
||||
receipt = make_stock_entry(item_code=item.name, target=source, qty=10, do_not_save=True, rate=10)
|
||||
for rate in rates[1:]:
|
||||
row = frappe.copy_doc(receipt.items[0], ignore_no_copy=False)
|
||||
row.basic_rate = rate
|
||||
receipt.append("items", row)
|
||||
|
||||
receipt.save()
|
||||
receipt.submit()
|
||||
|
||||
expected_queues = []
|
||||
for idx, rate in enumerate(rates, start=1):
|
||||
expected_queues.append(
|
||||
{"stock_queue": [[10, 10 * i] for i in range(1, idx + 1)]}
|
||||
)
|
||||
self.assertSLEs(receipt, expected_queues)
|
||||
|
||||
transfer = make_stock_entry(item_code=item.name, source=source, target=target, qty=10, do_not_save=True, rate=10)
|
||||
for rate in rates[1:]:
|
||||
row = frappe.copy_doc(transfer.items[0], ignore_no_copy=False)
|
||||
transfer.append("items", row)
|
||||
|
||||
transfer.save()
|
||||
transfer.submit()
|
||||
|
||||
# same exact queue should be transferred
|
||||
self.assertSLEs(transfer, expected_queues, sle_filters={"warehouse": target})
|
||||
|
||||
def test_fifo_multi_item_repack_consumption(self):
|
||||
rm = make_item("_TestFifoRepackRM")
|
||||
packed = make_item("_TestFifoRepackFinished")
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
rates = [10 * i for i in range(1, 5)]
|
||||
|
||||
receipt = make_stock_entry(item_code=rm.name, target=warehouse, qty=10, do_not_save=True, rate=10)
|
||||
for rate in rates[1:]:
|
||||
row = frappe.copy_doc(receipt.items[0], ignore_no_copy=False)
|
||||
row.basic_rate = rate
|
||||
receipt.append("items", row)
|
||||
|
||||
receipt.save()
|
||||
receipt.submit()
|
||||
|
||||
repack = make_stock_entry(item_code=rm.name, source=warehouse, qty=10,
|
||||
do_not_save=True, rate=10, purpose="Repack")
|
||||
for rate in rates[1:]:
|
||||
row = frappe.copy_doc(repack.items[0], ignore_no_copy=False)
|
||||
repack.append("items", row)
|
||||
|
||||
repack.append("items", {
|
||||
"item_code": packed.name,
|
||||
"t_warehouse": warehouse,
|
||||
"qty": 1,
|
||||
"transfer_qty": 1,
|
||||
})
|
||||
|
||||
repack.save()
|
||||
repack.submit()
|
||||
|
||||
# same exact queue should be transferred
|
||||
self.assertSLEs(repack, [
|
||||
{"incoming_rate": sum(rates) * 10}
|
||||
], sle_filters={"item_code": packed.name})
|
||||
|
||||
|
||||
def create_repack_entry(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
@@ -21,6 +21,7 @@ SLE_FIELDS = (
|
||||
"stock_value",
|
||||
"stock_value_difference",
|
||||
"valuation_rate",
|
||||
"voucher_detail_no",
|
||||
)
|
||||
|
||||
|
||||
@@ -60,10 +61,15 @@ def add_invariant_check_fields(sles):
|
||||
fifo_qty += qty
|
||||
fifo_value += qty * rate
|
||||
|
||||
if sle.actual_qty < 0:
|
||||
sle.consumption_rate = sle.stock_value_difference / sle.actual_qty
|
||||
|
||||
balance_qty += sle.actual_qty
|
||||
balance_stock_value += sle.stock_value_difference
|
||||
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
|
||||
balance_qty = sle.qty_after_transaction
|
||||
balance_qty = frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "qty")
|
||||
if balance_qty is None:
|
||||
balance_qty = sle.qty_after_transaction
|
||||
|
||||
sle.fifo_queue_qty = fifo_qty
|
||||
sle.fifo_stock_value = fifo_value
|
||||
@@ -145,9 +151,9 @@ def get_columns():
|
||||
"label": "Incoming Rate",
|
||||
},
|
||||
{
|
||||
"fieldname": "outgoing_rate",
|
||||
"fieldname": "consumption_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Outgoing Rate",
|
||||
"label": "Consumption Rate",
|
||||
},
|
||||
{
|
||||
"fieldname": "qty_after_transaction",
|
||||
|
||||
@@ -23,9 +23,18 @@ class NegativeStockError(frappe.ValidationError): pass
|
||||
class SerialNoExistsInFutureTransaction(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
_exceptions = frappe.local('stockledger_exceptions')
|
||||
|
||||
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
|
||||
""" Create SL entries from SL entry dicts
|
||||
|
||||
args:
|
||||
- allow_negative_stock: disable negative stock valiations if true
|
||||
- via_landed_cost_voucher: landed cost voucher cancels and reposts
|
||||
entries of purchase document. This flag is used to identify if
|
||||
cancellation and repost is happening via landed cost voucher, in
|
||||
such cases certain validations need to be ignored (like negative
|
||||
stock)
|
||||
"""
|
||||
from erpnext.controllers.stock_controller import future_sle_exists
|
||||
if sl_entries:
|
||||
cancel = sl_entries[0].get("is_cancelled")
|
||||
@@ -37,7 +46,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
|
||||
future_sle_exists(args, sl_entries)
|
||||
|
||||
for sle in sl_entries:
|
||||
if sle.serial_no:
|
||||
if sle.serial_no and not via_landed_cost_voucher:
|
||||
validate_serial_no(sle)
|
||||
|
||||
if cancel:
|
||||
@@ -459,6 +468,8 @@ class update_entries_after(object):
|
||||
|
||||
# rounding as per precision
|
||||
self.wh_data.stock_value = flt(self.wh_data.stock_value, self.precision)
|
||||
if not self.wh_data.qty_after_transaction:
|
||||
self.wh_data.stock_value = 0.0
|
||||
stock_value_difference = self.wh_data.stock_value - self.wh_data.prev_stock_value
|
||||
self.wh_data.prev_stock_value = self.wh_data.stock_value
|
||||
|
||||
@@ -622,9 +633,7 @@ class update_entries_after(object):
|
||||
if not self.wh_data.valuation_rate and sle.voucher_detail_no:
|
||||
allow_zero_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
|
||||
if not allow_zero_rate:
|
||||
self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse,
|
||||
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
|
||||
currency=erpnext.get_company_currency(sle.company), company=sle.company)
|
||||
self.wh_data.valuation_rate = self.get_fallback_rate(sle)
|
||||
|
||||
def get_incoming_value_for_serial_nos(self, sle, serial_nos):
|
||||
# get rate from serial nos within same company
|
||||
@@ -690,9 +699,7 @@ class update_entries_after(object):
|
||||
if not self.wh_data.valuation_rate and sle.voucher_detail_no:
|
||||
allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
|
||||
if not allow_zero_valuation_rate:
|
||||
self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse,
|
||||
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
|
||||
currency=erpnext.get_company_currency(sle.company), company=sle.company)
|
||||
self.wh_data.valuation_rate = self.get_fallback_rate(sle)
|
||||
|
||||
def get_fifo_values(self, sle):
|
||||
incoming_rate = flt(sle.incoming_rate)
|
||||
@@ -723,9 +730,7 @@ class update_entries_after(object):
|
||||
# Get valuation rate from last sle if exists or from valuation rate field in item master
|
||||
allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
|
||||
if not allow_zero_valuation_rate:
|
||||
_rate = get_valuation_rate(sle.item_code, sle.warehouse,
|
||||
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
|
||||
currency=erpnext.get_company_currency(sle.company), company=sle.company)
|
||||
_rate = self.get_fallback_rate(sle)
|
||||
else:
|
||||
_rate = 0
|
||||
|
||||
@@ -788,6 +793,13 @@ class update_entries_after(object):
|
||||
else:
|
||||
return 0
|
||||
|
||||
def get_fallback_rate(self, sle) -> float:
|
||||
"""When exact incoming rate isn't available use any of other "average" rates as fallback.
|
||||
This should only get used for negative stock."""
|
||||
return get_valuation_rate(sle.item_code, sle.warehouse,
|
||||
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
|
||||
currency=erpnext.get_company_currency(sle.company), company=sle.company)
|
||||
|
||||
def get_sle_before_datetime(self, args):
|
||||
"""get previous stock ledger entry before current time-bucket"""
|
||||
sle = get_stock_ledger_entries(args, "<", "desc", "limit 1", for_update=False)
|
||||
|
||||
Reference in New Issue
Block a user