Merge branch 'version-13' of https://github.com/frappe/erpnext into enterprise-hotfix

This commit is contained in:
Deepesh Garg
2022-03-11 16:55:03 +05:30
300 changed files with 8014 additions and 2550 deletions

View File

@@ -293,6 +293,7 @@ def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None):
join `tabStock Ledger Entry` ignore index (item_code, warehouse)
on (`tabBatch`.batch_id = `tabStock Ledger Entry`.batch_no )
where `tabStock Ledger Entry`.item_code = %s and `tabStock Ledger Entry`.warehouse = %s
and `tabStock Ledger Entry`.is_cancelled = 0
and (`tabBatch`.expiry_date >= CURDATE() or `tabBatch`.expiry_date IS NULL) {0}
group by batch_id
order by `tabBatch`.expiry_date ASC, `tabBatch`.creation ASC
@@ -313,3 +314,30 @@ def make_batch(args):
if frappe.db.get_value("Item", args.item, "has_batch_no"):
args.doctype = "Batch"
frappe.get_doc(args).insert().name
@frappe.whitelist()
def get_pos_reserved_batch_qty(filters):
import json
from frappe.query_builder.functions import Sum
if isinstance(filters, str):
filters = json.loads(filters)
p = frappe.qb.DocType("POS Invoice").as_("p")
item = frappe.qb.DocType("POS Invoice Item").as_("item")
sum_qty = Sum(item.qty).as_("qty")
reserved_batch_qty = frappe.qb.from_(p).from_(item).select(sum_qty).where(
(p.name == item.parent) &
(p.consolidated_invoice.isnull()) &
(p.status != "Consolidated") &
(p.docstatus == 1) &
(item.docstatus == 1) &
(item.item_code == filters.get('item_code')) &
(item.warehouse == filters.get('warehouse')) &
(item.batch_no == filters.get('batch_no'))
).run()
flt_reserved_batch_qty = flt(reserved_batch_qty[0][0])
return flt_reserved_batch_qty

View File

@@ -33,6 +33,7 @@
"oldfieldtype": "Link",
"options": "Warehouse",
"read_only": 1,
"reqd": 1,
"search_index": 1
},
{
@@ -46,6 +47,7 @@
"oldfieldtype": "Link",
"options": "Item",
"read_only": 1,
"reqd": 1,
"search_index": 1
},
{
@@ -169,10 +171,11 @@
"idx": 1,
"in_create": 1,
"links": [],
"modified": "2021-03-30 23:09:39.572776",
"modified": "2022-01-30 17:04:54.715288",
"modified_by": "Administrator",
"module": "Stock",
"name": "Bin",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -200,5 +203,6 @@
"quick_entry": 1,
"search_fields": "item_code,warehouse",
"sort_field": "modified",
"sort_order": "ASC"
"sort_order": "ASC",
"states": []
}

View File

@@ -31,23 +31,9 @@ class Bin(Document):
def update_reserved_qty_for_production(self):
'''Update qty reserved for production from Production Item tables
in open work orders'''
self.reserved_qty_for_production = frappe.db.sql('''
SELECT
SUM(CASE WHEN ifnull(skip_transfer, 0) = 0 THEN
item.required_qty - item.transferred_qty
ELSE
item.required_qty - item.consumed_qty END)
END
FROM `tabWork Order` pro, `tabWork Order Item` item
WHERE
item.item_code = %s
and item.parent = pro.name
and pro.docstatus = 1
and item.source_warehouse = %s
and pro.status not in ("Stopped", "Completed")
and (item.required_qty > item.transferred_qty or item.required_qty > item.consumed_qty)
''', (self.item_code, self.warehouse))[0][0]
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse)
self.set_projected_qty()
self.db_set('reserved_qty_for_production', flt(self.reserved_qty_for_production))
@@ -96,7 +82,7 @@ class Bin(Document):
self.db_set('projected_qty', self.projected_qty)
def on_doctype_update():
frappe.db.add_index("Bin", ["item_code", "warehouse"])
frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse")
def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_voucher=False):

View File

@@ -1,9 +1,36 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
import frappe
# test_records = frappe.get_test_records('Bin')
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.utils import _create_bin
from erpnext.tests.utils import ERPNextTestCase
class TestBin(unittest.TestCase):
pass
class TestBin(ERPNextTestCase):
def test_concurrent_inserts(self):
""" Ensure no duplicates are possible in case of concurrent inserts"""
item_code = "_TestConcurrentBin"
make_item(item_code)
warehouse = "_Test Warehouse - _TC"
bin1 = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse)
bin1.insert()
bin2 = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse)
with self.assertRaises(frappe.UniqueValidationError):
bin2.insert()
# util method should handle it
bin = _create_bin(item_code, warehouse)
self.assertEqual(bin.item_code, item_code)
frappe.db.rollback()
def test_index_exists(self):
indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1)
if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes):
self.fail(f"Expected unique index on item-warehouse")

View File

@@ -14,6 +14,7 @@ from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.doctype.batch.batch import set_batch_nos
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no
from erpnext.stock.utils import calculate_mapped_packed_items_return
form_grid_templates = {
"items": "templates/form_grid/item_grid.html"
@@ -128,8 +129,12 @@ class DeliveryNote(SellingController):
self.validate_uom_is_integer("uom", "qty")
self.validate_with_previous_doc()
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
make_packing_list(self)
# Keeps mapped packed_items in case product bundle is updated.
if self.is_return and self.return_against:
calculate_mapped_packed_items_return(self)
else:
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
make_packing_list(self)
if self._action != 'submit' and not self.is_return:
set_batch_nos(self, 'warehouse', throw=True)
@@ -334,17 +339,31 @@ class DeliveryNote(SellingController):
frappe.throw(_("Could not create Credit Note automatically, please uncheck 'Issue Credit Note' and submit again"))
def update_billed_amount_based_on_so(so_detail, update_modified=True):
from frappe.query_builder.functions import Sum
# Billed against Sales Order directly
billed_against_so = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item`
where so_detail=%s and (dn_detail is null or dn_detail = '') and docstatus=1""", so_detail)
si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item")
sum_amount = Sum(si_item.amount).as_("amount")
billed_against_so = frappe.qb.from_(si_item).select(sum_amount).where(
(si_item.so_detail == so_detail) &
((si_item.dn_detail.isnull()) | (si_item.dn_detail == '')) &
(si_item.docstatus == 1)
).run()
billed_against_so = billed_against_so and billed_against_so[0][0] or 0
# Get all Delivery Note Item rows against the Sales Order Item row
dn_details = frappe.db.sql("""select dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent
from `tabDelivery Note Item` dn_item, `tabDelivery Note` dn
where dn.name=dn_item.parent and dn_item.so_detail=%s
and dn.docstatus=1 and dn.is_return = 0
order by dn.posting_date asc, dn.posting_time asc, dn.name asc""", so_detail, as_dict=1)
dn = frappe.qb.DocType("Delivery Note").as_("dn")
dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item")
dn_details = frappe.qb.from_(dn).from_(dn_item).select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent).where(
(dn.name == dn_item.parent) &
(dn_item.so_detail == so_detail) &
(dn.docstatus == 1) &
(dn.is_return == 0)
).orderby(
dn.posting_date, dn.posting_time, dn.name
).run(as_dict=True)
updated_dn = []
for dnd in dn_details:

View File

@@ -386,8 +386,7 @@ class TestDeliveryNote(ERPNextTestCase):
self.assertEqual(actual_qty, 25)
# return bundled item
dn1 = create_delivery_note(item_code='_Test Product Bundle Item', is_return=1,
return_against=dn.name, qty=-2, rate=500, company=company, warehouse="Stores - TCP1", expense_account="Cost of Goods Sold - TCP1", cost_center="Main - TCP1")
dn1 = create_return_delivery_note(source_name=dn.name, rate=500, qty=-2)
# qty after return
actual_qty = get_qty_after_transaction(warehouse="Stores - TCP1")
@@ -823,6 +822,15 @@ class TestDeliveryNote(ERPNextTestCase):
automatically_fetch_payment_terms(enable=0)
def create_return_delivery_note(**args):
args = frappe._dict(args)
from erpnext.controllers.sales_and_purchase_return import make_return_doc
doc = make_return_doc("Delivery Note", args.source_name, None)
doc.items[0].rate = args.rate
doc.items[0].qty = args.qty
doc.submit()
return doc
def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note")
args = frappe._dict(args)

View File

@@ -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": []
}

View File

@@ -380,8 +380,7 @@ $.extend(erpnext.item, {
// Show Stock Levels only if is_stock_item
if (frm.doc.is_stock_item) {
frappe.require('assets/js/item-dashboard.min.js', function() {
frm.dashboard.parent.find('.stock-levels').remove();
const section = frm.dashboard.add_section('', __("Stock Levels"), 'stock-levels');
const section = frm.dashboard.add_section('', __("Stock Levels"));
erpnext.item.item_dashboard = new erpnext.stock.ItemDashboard({
parent: section,
item_code: frm.doc.name,
@@ -546,7 +545,7 @@ $.extend(erpnext.item, {
let selected_attributes = {};
me.multiple_variant_dialog.$wrapper.find('.form-column').each((i, col) => {
if(i===0) return;
let attribute_name = $(col).find('label').html();
let attribute_name = $(col).find('label').html().trim();
selected_attributes[attribute_name] = [];
let checked_opts = $(col).find('.checkbox input');
checked_opts.each((i, opt) => {
@@ -595,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;

View File

@@ -219,18 +219,20 @@ class Item(Document):
self.item_code))
def add_default_uom_in_conversion_factor_table(self):
uom_conv_list = [d.uom for d in self.get("uoms")]
if self.stock_uom not in uom_conv_list:
ch = self.append('uoms', {})
ch.uom = self.stock_uom
ch.conversion_factor = 1
if not self.is_new() and self.has_value_changed("stock_uom"):
self.uoms = []
frappe.msgprint(
_("Successfully changed Stock UOM, please redefine conversion factors for new UOM."),
alert=True,
)
to_remove = []
for d in self.get("uoms"):
if d.conversion_factor == 1 and d.uom != self.stock_uom:
to_remove.append(d)
uoms_list = [d.uom for d in self.get("uoms")]
[self.remove(d) for d in to_remove]
if self.stock_uom not in uoms_list:
self.append("uoms", {
"uom": self.stock_uom,
"conversion_factor": 1
})
def update_website_item(self):
"""Update Website Item if change in Item impacts it."""
@@ -347,14 +349,6 @@ class Item(Document):
frappe.throw(_("Barcode {0} is not a valid {1} code").format(
item_barcode.barcode, item_barcode.barcode_type), InvalidBarcode)
if item_barcode.barcode != item_barcode.name:
# if barcode is getting updated , the row name has to reset.
# Delete previous old row doc and re-enter row as if new to reset name in db.
item_barcode.set("__islocal", True)
item_barcode_entry_name = item_barcode.name
item_barcode.name = None
frappe.delete_doc("Item Barcode", item_barcode_entry_name)
def validate_warehouse_for_reorder(self):
'''Validate Reorder level table for duplicate and conditional mandatory'''
warehouse = []
@@ -405,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):
@@ -469,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.
@@ -486,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):

View File

@@ -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')
@@ -573,6 +594,16 @@ class TestItem(ERPNextTestCase):
except frappe.ValidationError as e:
self.fail(f"UoM change not allowed even though no SLE / BIN with positive qty exists: {e}")
def test_erasure_of_old_conversions(self):
item = create_item("_item change uom")
item.stock_uom = "Gram"
item.append("uoms", frappe._dict(uom="Box", conversion_factor=2))
item.save()
item.reload()
item.stock_uom = "Nos"
item.save()
self.assertEqual(len(item.uoms), 1)
def test_validate_stock_item(self):
self.assertRaises(frappe.ValidationError, validate_is_stock_item, "_Test Non Stock Item")

View File

@@ -4,11 +4,13 @@
import frappe
from frappe.utils import flt
from frappe.utils import add_to_date, flt, now
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
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,
@@ -28,7 +30,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
"voucher_type": pr.doctype,
"voucher_no": pr.name,
"item_code": "_Test Item",
"warehouse": "Stores - TCP1"
"warehouse": "Stores - TCP1",
"is_cancelled": 0,
},
fieldname=["qty_after_transaction", "stock_value"], as_dict=1)
@@ -41,14 +44,39 @@ class TestLandedCostVoucher(ERPNextTestCase):
"voucher_type": pr.doctype,
"voucher_no": pr.name,
"item_code": "_Test Item",
"warehouse": "Stores - TCP1"
"warehouse": "Stores - TCP1",
"is_cancelled": 0,
},
fieldname=["qty_after_transaction", "stock_value"], as_dict=1)
self.assertEqual(last_sle.qty_after_transaction, last_sle_after_landed_cost.qty_after_transaction)
self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 25.0)
# assert after submit
self.assertPurchaseReceiptLCVGLEntries(pr)
# Mess up cancelled SLE modified timestamp to check
# if they aren't effective in any business logic.
frappe.db.set_value("Stock Ledger Entry",
{
"is_cancelled": 1,
"voucher_type": pr.doctype,
"voucher_no": pr.name
},
"is_cancelled", 1,
modified=add_to_date(now(), hours=1, as_datetime=True, as_string=True)
)
items, warehouses = pr.get_items_and_warehouses()
update_gl_entries_after(pr.posting_date, pr.posting_time,
warehouses, items, company=pr.company)
# reassert after reposting
self.assertPurchaseReceiptLCVGLEntries(pr)
def assertPurchaseReceiptLCVGLEntries(self, pr):
gl_entries = get_gl_entries("Purchase Receipt", pr.name)
self.assertTrue(gl_entries)
@@ -74,8 +102,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
for gle in gl_entries:
if not gle.get('is_cancelled'):
self.assertEqual(expected_values[gle.account][0], gle.debit)
self.assertEqual(expected_values[gle.account][1], gle.credit)
self.assertEqual(expected_values[gle.account][0], gle.debit, msg=f"incorrect debit for {gle.account}")
self.assertEqual(expected_values[gle.account][1], gle.credit, msg=f"incorrect credit for {gle.account}")
def test_landed_cost_voucher_against_purchase_invoice(self):
@@ -150,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)

View File

@@ -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:
@@ -534,6 +539,7 @@ def raise_work_orders(material_request):
"stock_uom": d.stock_uom,
"expected_delivery_date": d.schedule_date,
"sales_order": d.sales_order,
"sales_order_item": d.get("sales_order_item"),
"bom_no": get_item_details(d.item_code).bom_no,
"material_request": mr.name,
"material_request_item": d.name,

View File

@@ -26,6 +26,7 @@
"section_break_13",
"actual_qty",
"projected_qty",
"ordered_qty",
"column_break_16",
"incoming_rate",
"page_break",
@@ -218,21 +219,27 @@
"label": "Conversion Factor"
},
{
"fetch_from": "item_code.valuation_rate",
"fetch_if_empty": 1,
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"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": "2021-09-01 15:10:29.646399",
"modified": "2022-02-22 12:57:45.325488",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packed Item",
@@ -240,5 +247,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -8,187 +8,253 @@ import json
import frappe
from frappe.model.document import Document
from frappe.utils import cstr, flt
from frappe.utils import flt
from erpnext.stock.get_item_details import get_item_details
from erpnext.stock.get_item_details import get_item_details, get_price_list_rate
class PackedItem(Document):
pass
def get_product_bundle_items(item_code):
return frappe.db.sql("""select t1.item_code, t1.qty, t1.uom, t1.description
from `tabProduct Bundle Item` t1, `tabProduct Bundle` t2
where t2.new_item_code=%s and t1.parent = t2.name order by t1.idx""", item_code, as_dict=1)
def get_packing_item_details(item, company):
return frappe.db.sql("""
select i.item_name, i.is_stock_item, i.description, i.stock_uom, id.default_warehouse
from `tabItem` i LEFT JOIN `tabItem Default` id ON id.parent=i.name and id.company=%s
where i.name = %s""",
(company, item), as_dict = 1)[0]
def get_bin_qty(item, warehouse):
det = frappe.db.sql("""select actual_qty, projected_qty from `tabBin`
where item_code = %s and warehouse = %s""", (item, warehouse), as_dict = 1)
return det and det[0] or frappe._dict()
def update_packing_list_item(doc, packing_item_code, qty, main_item_row, description):
if doc.amended_from:
old_packed_items_map = get_old_packed_item_details(doc.packed_items)
else:
old_packed_items_map = False
item = get_packing_item_details(packing_item_code, doc.company)
# check if exists
exists = 0
for d in doc.get("packed_items"):
if d.parent_item == main_item_row.item_code and d.item_code == packing_item_code:
if d.parent_detail_docname != main_item_row.name:
d.parent_detail_docname = main_item_row.name
pi, exists = d, 1
break
if not exists:
pi = doc.append('packed_items', {})
pi.parent_item = main_item_row.item_code
pi.item_code = packing_item_code
pi.item_name = item.item_name
pi.parent_detail_docname = main_item_row.name
pi.uom = item.stock_uom
pi.qty = flt(qty)
pi.conversion_factor = main_item_row.conversion_factor
if description and not pi.description:
pi.description = description
if not pi.warehouse and not doc.amended_from:
pi.warehouse = (main_item_row.warehouse if ((doc.get('is_pos') or item.is_stock_item \
or not item.default_warehouse) and main_item_row.warehouse) else item.default_warehouse)
if not pi.batch_no and not doc.amended_from:
pi.batch_no = cstr(main_item_row.get("batch_no"))
if not pi.target_warehouse:
pi.target_warehouse = main_item_row.get("target_warehouse")
bin = get_bin_qty(packing_item_code, pi.warehouse)
pi.actual_qty = flt(bin.get("actual_qty"))
pi.projected_qty = flt(bin.get("projected_qty"))
if old_packed_items_map and old_packed_items_map.get((packing_item_code, main_item_row.item_code)):
pi.batch_no = old_packed_items_map.get((packing_item_code, main_item_row.item_code))[0].batch_no
pi.serial_no = old_packed_items_map.get((packing_item_code, main_item_row.item_code))[0].serial_no
pi.warehouse = old_packed_items_map.get((packing_item_code, main_item_row.item_code))[0].warehouse
def make_packing_list(doc):
"""make packing list for Product Bundle item"""
if doc.get("_action") and doc._action == "update_after_submit": return
parent_items = []
for d in doc.get("items"):
if frappe.db.get_value("Product Bundle", {"new_item_code": d.item_code}):
for i in get_product_bundle_items(d.item_code):
update_packing_list_item(doc, i.item_code, flt(i.qty)*flt(d.stock_qty), d, i.description)
if [d.item_code, d.name] not in parent_items:
parent_items.append([d.item_code, d.name])
cleanup_packing_list(doc, parent_items)
if frappe.db.get_single_value("Selling Settings", "editable_bundle_item_rates"):
update_product_bundle_price(doc, parent_items)
def cleanup_packing_list(doc, parent_items):
"""Remove all those child items which are no longer present in main item table"""
delete_list = []
for d in doc.get("packed_items"):
if [d.parent_item, d.parent_detail_docname] not in parent_items:
# mark for deletion from doclist
delete_list.append(d)
if not delete_list:
return doc
packed_items = doc.get("packed_items")
doc.set("packed_items", [])
for d in packed_items:
if d not in delete_list:
add_item_to_packing_list(doc, d)
def add_item_to_packing_list(doc, packed_item):
doc.append("packed_items", {
'parent_item': packed_item.parent_item,
'item_code': packed_item.item_code,
'item_name': packed_item.item_name,
'uom': packed_item.uom,
'qty': packed_item.qty,
'rate': packed_item.rate,
'conversion_factor': packed_item.conversion_factor,
'description': packed_item.description,
'warehouse': packed_item.warehouse,
'batch_no': packed_item.batch_no,
'actual_batch_qty': packed_item.actual_batch_qty,
'serial_no': packed_item.serial_no,
'target_warehouse': packed_item.target_warehouse,
'actual_qty': packed_item.actual_qty,
'projected_qty': packed_item.projected_qty,
'incoming_rate': packed_item.incoming_rate,
'prevdoc_doctype': packed_item.prevdoc_doctype,
'parent_detail_docname': packed_item.parent_detail_docname
})
def update_product_bundle_price(doc, parent_items):
"""Updates the prices of Product Bundles based on the rates of the Items in the bundle."""
if not doc.get('items'):
"Make/Update packing list for Product Bundle Item."
if doc.get("_action") and doc._action == "update_after_submit":
return
parent_items_index = 0
bundle_price = 0
parent_items_price, reset = {}, False
set_price_from_children = frappe.db.get_single_value("Selling Settings", "editable_bundle_item_rates")
for bundle_item in doc.get("packed_items"):
if parent_items[parent_items_index][0] == bundle_item.parent_item:
bundle_item_rate = bundle_item.rate if bundle_item.rate else 0
bundle_price += bundle_item.qty * bundle_item_rate
else:
update_parent_item_price(doc, parent_items[parent_items_index][0], bundle_price)
stale_packed_items_table = get_indexed_packed_items_table(doc)
bundle_item_rate = bundle_item.rate if bundle_item.rate else 0
bundle_price = bundle_item.qty * bundle_item_rate
parent_items_index += 1
reset = reset_packing_list(doc)
# for the last product bundle
if doc.get("packed_items"):
update_parent_item_price(doc, parent_items[parent_items_index][0], bundle_price)
for item_row in doc.get("items"):
if frappe.db.exists("Product Bundle", {"new_item_code": item_row.item_code}):
for bundle_item in get_product_bundle_items(item_row.item_code):
pi_row = add_packed_item_row(
doc=doc, packing_item=bundle_item,
main_item_row=item_row, packed_items_table=stale_packed_items_table,
reset=reset
)
item_data = get_packed_item_details(bundle_item.item_code, doc.company)
update_packed_item_basic_data(item_row, pi_row, bundle_item, item_data)
update_packed_item_stock_data(item_row, pi_row, bundle_item, item_data, doc)
update_packed_item_price_data(pi_row, item_data, doc)
update_packed_item_from_cancelled_doc(item_row, bundle_item, pi_row, doc)
def update_parent_item_price(doc, parent_item_code, bundle_price):
parent_item_doc = doc.get('items', {'item_code': parent_item_code})[0]
if set_price_from_children: # create/update bundle item wise price dict
update_product_bundle_rate(parent_items_price, pi_row)
current_parent_item_price = parent_item_doc.amount
if current_parent_item_price != bundle_price:
parent_item_doc.amount = bundle_price
update_parent_item_rate(parent_item_doc, bundle_price)
if parent_items_price:
set_product_bundle_rate_amount(doc, parent_items_price) # set price in bundle item
def update_parent_item_rate(parent_item_doc, bundle_price):
parent_item_doc.rate = bundle_price/parent_item_doc.qty
def get_indexed_packed_items_table(doc):
"""
Create dict from stale packed items table like:
{(Parent Item 1, Bundle Item 1, ae4b5678): {...}, (key): {value}}
@frappe.whitelist()
def get_items_from_product_bundle(args):
args = json.loads(args)
items = []
bundled_items = get_product_bundle_items(args["item_code"])
for item in bundled_items:
args.update({
"item_code": item.item_code,
"qty": flt(args["quantity"]) * flt(item.qty)
})
items.append(get_item_details(args))
Use: to quickly retrieve/check if row existed in table instead of looping n times
"""
indexed_table = {}
for packed_item in doc.get("packed_items"):
key = (packed_item.parent_item, packed_item.item_code, packed_item.parent_detail_docname)
indexed_table[key] = packed_item
return items
return indexed_table
def reset_packing_list(doc):
"Conditionally reset the table and return if it was reset or not."
reset_table = False
doc_before_save = doc.get_doc_before_save()
if doc_before_save:
# reset table if:
# 1. items were deleted
# 2. if bundle item replaced by another item (same no. of items but different items)
# we maintain list to track recurring item rows as well
items_before_save = [item.item_code for item in doc_before_save.get("items")]
items_after_save = [item.item_code for item in doc.get("items")]
reset_table = items_before_save != items_after_save
else:
# reset: if via Update Items OR
# if new mapped doc with packed items set (SO -> DN)
# (cannot determine action)
reset_table = True
if reset_table:
doc.set("packed_items", [])
return reset_table
def get_product_bundle_items(item_code):
product_bundle = frappe.qb.DocType("Product Bundle")
product_bundle_item = frappe.qb.DocType("Product Bundle Item")
query = (
frappe.qb.from_(product_bundle_item)
.join(product_bundle).on(product_bundle_item.parent == product_bundle.name)
.select(
product_bundle_item.item_code,
product_bundle_item.qty,
product_bundle_item.uom,
product_bundle_item.description
).where(
product_bundle.new_item_code == item_code
).orderby(
product_bundle_item.idx
)
)
return query.run(as_dict=True)
def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, reset):
"""Add and return packed item row.
doc: Transaction document
packing_item (dict): Packed Item details
main_item_row (dict): Items table row corresponding to packed item
packed_items_table (dict): Packed Items table before save (indexed)
reset (bool): State if table is reset or preserved as is
"""
exists, pi_row = False, {}
# check if row already exists in packed items table
key = (main_item_row.item_code, packing_item.item_code, main_item_row.name)
if packed_items_table.get(key):
pi_row, exists = packed_items_table.get(key), True
if not exists:
pi_row = doc.append('packed_items', {})
elif reset: # add row if row exists but table is reset
pi_row.idx, pi_row.name = None, None
pi_row = doc.append('packed_items', pi_row)
return pi_row
def get_packed_item_details(item_code, company):
item = frappe.qb.DocType("Item")
item_default = frappe.qb.DocType("Item Default")
query = (
frappe.qb.from_(item)
.left_join(item_default)
.on(
(item_default.parent == item.name)
& (item_default.company == company)
).select(
item.item_name, item.is_stock_item,
item.description, item.stock_uom,
item.valuation_rate,
item_default.default_warehouse
).where(
item.name == item_code
)
)
return query.run(as_dict=True)[0]
def update_packed_item_basic_data(main_item_row, pi_row, packing_item, item_data):
pi_row.parent_item = main_item_row.item_code
pi_row.parent_detail_docname = main_item_row.name
pi_row.item_code = packing_item.item_code
pi_row.item_name = item_data.item_name
pi_row.uom = item_data.stock_uom
pi_row.qty = flt(packing_item.qty) * flt(main_item_row.stock_qty)
pi_row.conversion_factor = main_item_row.conversion_factor
if not pi_row.description:
pi_row.description = packing_item.get("description")
def update_packed_item_stock_data(main_item_row, pi_row, packing_item, item_data, doc):
# TODO batch_no, actual_batch_qty, incoming_rate
if not pi_row.warehouse and not doc.amended_from:
fetch_warehouse = (doc.get('is_pos') or item_data.is_stock_item or not item_data.default_warehouse)
pi_row.warehouse = (main_item_row.warehouse if (fetch_warehouse and main_item_row.warehouse)
else item_data.default_warehouse)
if not pi_row.target_warehouse:
pi_row.target_warehouse = main_item_row.get("target_warehouse")
bin = get_packed_item_bin_qty(packing_item.item_code, pi_row.warehouse)
pi_row.actual_qty = flt(bin.get("actual_qty"))
pi_row.projected_qty = flt(bin.get("projected_qty"))
def update_packed_item_price_data(pi_row, item_data, doc):
"Set price as per price list or from the Item master."
if pi_row.rate:
return
item_doc = frappe.get_cached_doc("Item", pi_row.item_code)
row_data = pi_row.as_dict().copy()
row_data.update({
"company": doc.get("company"),
"price_list": doc.get("selling_price_list"),
"currency": doc.get("currency")
})
rate = get_price_list_rate(row_data, item_doc).get("price_list_rate")
pi_row.rate = rate or item_data.get("valuation_rate") or 0.0
def update_packed_item_from_cancelled_doc(main_item_row, packing_item, pi_row, doc):
"Update packed item row details from cancelled doc into amended doc."
prev_doc_packed_items_map = None
if doc.amended_from:
prev_doc_packed_items_map = get_cancelled_doc_packed_item_details(doc.packed_items)
if prev_doc_packed_items_map and prev_doc_packed_items_map.get((packing_item.item_code, main_item_row.item_code)):
prev_doc_row = prev_doc_packed_items_map.get((packing_item.item_code, main_item_row.item_code))
pi_row.batch_no = prev_doc_row[0].batch_no
pi_row.serial_no = prev_doc_row[0].serial_no
pi_row.warehouse = prev_doc_row[0].warehouse
def get_packed_item_bin_qty(item, warehouse):
bin_data = frappe.db.get_values(
"Bin",
fieldname=["actual_qty", "projected_qty"],
filters={"item_code": item, "warehouse": warehouse},
as_dict=True
)
return bin_data[0] if bin_data else {}
def get_cancelled_doc_packed_item_details(old_packed_items):
prev_doc_packed_items_map = {}
for items in old_packed_items:
prev_doc_packed_items_map.setdefault((items.item_code ,items.parent_item), []).append(items.as_dict())
return prev_doc_packed_items_map
def update_product_bundle_rate(parent_items_price, pi_row):
"""
Update the price dict of Product Bundles based on the rates of the Items in the bundle.
Stucture:
{(Bundle Item 1, ae56fgji): 150.0, (Bundle Item 2, bc78fkjo): 200.0}
"""
key = (pi_row.parent_item, pi_row.parent_detail_docname)
rate = parent_items_price.get(key)
if not rate:
parent_items_price[key] = 0.0
parent_items_price[key] += flt(pi_row.rate)
def set_product_bundle_rate_amount(doc, parent_items_price):
"Set cumulative rate and amount in bundle item."
for item in doc.get("items"):
bundle_rate = parent_items_price.get((item.item_code, item.name))
if bundle_rate and bundle_rate != item.rate:
item.rate = bundle_rate
item.amount = flt(bundle_rate * item.qty)
def on_doctype_update():
frappe.db.add_index("Packed Item", ["item_code", "warehouse"])
def get_old_packed_item_details(old_packed_items):
old_packed_items_map = {}
for items in old_packed_items:
old_packed_items_map.setdefault((items.item_code ,items.parent_item), []).append(items.as_dict())
return old_packed_items_map
@frappe.whitelist()
def get_items_from_product_bundle(row):
row, items = json.loads(row), []
bundled_items = get_product_bundle_items(row["item_code"])
for item in bundled_items:
row.update({
"item_code": item.item_code,
"qty": flt(row["quantity"]) * flt(item.qty)
})
items.append(get_item_details(row))
return items

View File

@@ -0,0 +1,158 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe.utils import add_to_date, nowdate
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestPackedItem(ERPNextTestCase):
"Test impact on Packed Items table in various scenarios."
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.bundle = "_Test Product Bundle X"
cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"]
make_item(cls.bundle, {"is_stock_item": 0})
for item in cls.bundle_items:
make_item(item, {"is_stock_item": 1})
make_item("_Test Normal Stock Item", {"is_stock_item": 1})
make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
def test_adding_bundle_item(self):
"Test impact on packed items if bundle item row is added."
so = make_sales_order(item_code = self.bundle, qty=1,
do_not_submit=True)
self.assertEqual(so.items[0].qty, 1)
self.assertEqual(len(so.packed_items), 2)
self.assertEqual(so.packed_items[0].item_code, self.bundle_items[0])
self.assertEqual(so.packed_items[0].qty, 2)
def test_updating_bundle_item(self):
"Test impact on packed items if bundle item row is updated."
so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True)
so.items[0].qty = 2 # change qty
so.save()
self.assertEqual(so.packed_items[0].qty, 4)
self.assertEqual(so.packed_items[1].qty, 4)
# change item code to non bundle item
so.items[0].item_code = "_Test Normal Stock Item"
so.save()
self.assertEqual(len(so.packed_items), 0)
def test_recurring_bundle_item(self):
"Test impact on packed items if same bundle item is added and removed."
so_items = []
for qty in [2, 4, 6, 8]:
so_items.append({
"item_code": self.bundle,
"qty": qty,
"rate": 400,
"warehouse": "_Test Warehouse - _TC"
})
# create SO with recurring bundle item
so = make_sales_order(item_list=so_items, do_not_submit=True)
# check alternate rows for qty
self.assertEqual(len(so.packed_items), 8)
self.assertEqual(so.packed_items[1].item_code, self.bundle_items[1])
self.assertEqual(so.packed_items[1].qty, 4)
self.assertEqual(so.packed_items[3].qty, 8)
self.assertEqual(so.packed_items[5].qty, 12)
self.assertEqual(so.packed_items[7].qty, 16)
# delete intermediate row (2nd)
del so.items[1]
so.save()
# check alternate rows for qty
self.assertEqual(len(so.packed_items), 6)
self.assertEqual(so.packed_items[1].qty, 4)
self.assertEqual(so.packed_items[3].qty, 12)
self.assertEqual(so.packed_items[5].qty, 16)
# delete last row
del so.items[2]
so.save()
# check alternate rows for qty
self.assertEqual(len(so.packed_items), 4)
self.assertEqual(so.packed_items[1].qty, 4)
self.assertEqual(so.packed_items[3].qty, 12)
@change_settings("Selling Settings", {"editable_bundle_item_rates": 1})
def test_bundle_item_cumulative_price(self):
"Test if Bundle Item rate is cumulative from packed items."
so = make_sales_order(item_code=self.bundle, qty=2, do_not_submit=True)
so.packed_items[0].rate = 150
so.packed_items[1].rate = 200
so.save()
self.assertEqual(so.items[0].rate, 350)
self.assertEqual(so.items[0].amount, 700)
def test_newly_mapped_doc_packed_items(self):
"Test impact on packed items in newly mapped DN from SO."
so_items = []
for qty in [2, 4]:
so_items.append({
"item_code": self.bundle,
"qty": qty,
"rate": 400,
"warehouse": "_Test Warehouse - _TC"
})
# create SO with recurring bundle item
so = make_sales_order(item_list=so_items)
dn = make_delivery_note(so.name)
dn.items[1].qty = 3 # change second row qty for inserting doc
dn.save()
self.assertEqual(len(dn.packed_items), 4)
self.assertEqual(dn.packed_items[2].qty, 6)
self.assertEqual(dn.packed_items[3].qty, 6)
def test_reposting_packed_items(self):
warehouse = "Stores - TCP1"
company = "_Test Company with perpetual inventory"
today = nowdate()
yesterday = add_to_date(today, days=-1, as_string=True)
for item in self.bundle_items:
make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=100, posting_date=today)
so = make_sales_order(item_code = self.bundle, qty=1, company=company, warehouse=warehouse)
dn = make_delivery_note(so.name)
dn.save()
dn.submit()
gles = get_gl_entries(dn.doctype, dn.name)
credit_before_repost = sum(gle.credit for gle in gles)
# backdated stock entry
for item in self.bundle_items:
make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=200, posting_date=yesterday)
# assert correct reposting
gles = get_gl_entries(dn.doctype, dn.name)
credit_after_reposting = sum(gle.credit for gle in gles)
self.assertNotEqual(credit_before_repost, credit_after_reposting)
self.assertAlmostEqual(credit_after_reposting, 2 * credit_before_repost)

View File

@@ -106,6 +106,8 @@
"terms",
"bill_no",
"bill_date",
"accounting_details_section",
"provisional_expense_account",
"more_info",
"project",
"status",
@@ -1144,16 +1146,30 @@
"label": "Represents Company",
"options": "Company",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "accounting_details_section",
"fieldtype": "Section Break",
"label": "Accounting Details"
},
{
"fieldname": "provisional_expense_account",
"fieldtype": "Link",
"hidden": 1,
"label": "Provisional Expense Account",
"options": "Account"
}
],
"icon": "fa fa-truck",
"idx": 261,
"is_submittable": 1,
"links": [],
"modified": "2021-09-28 13:11:10.181328",
"modified": "2022-02-01 11:40:52.690984",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -1214,6 +1230,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"timeline_field": "supplier",
"title_field": "title",
"track_changes": 1

View File

@@ -11,6 +11,7 @@ from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, flt, getdate, nowdate
from six import iteritems
import erpnext
from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
@@ -115,6 +116,7 @@ class PurchaseReceipt(BuyingController):
self.validate_uom_is_integer("uom", ["qty", "received_qty"])
self.validate_uom_is_integer("stock_uom", "stock_qty")
self.validate_cwip_accounts()
self.validate_provisional_expense_account()
self.check_on_hold_or_closed_status()
@@ -136,6 +138,15 @@ class PurchaseReceipt(BuyingController):
company = self.company)
break
def validate_provisional_expense_account(self):
provisional_accounting_for_non_stock_items = \
cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items'))
if provisional_accounting_for_non_stock_items:
default_provisional_account = self.get_company_default("default_provisional_account")
if not self.provisional_expense_account:
self.provisional_expense_account = default_provisional_account
def validate_with_previous_doc(self):
super(PurchaseReceipt, self).validate_with_previous_doc({
"Purchase Order": {
@@ -257,23 +268,22 @@ class PurchaseReceipt(BuyingController):
return process_gl_map(gl_entries)
def make_item_gl_entries(self, gl_entries, warehouse_account=None):
stock_rbnb = self.get_company_default("stock_received_but_not_billed")
landed_cost_entries = get_item_account_wise_additional_cost(self.name)
expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
auto_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, 'enable_perpetual_inventory_for_non_stock_items'))
if erpnext.is_perpetual_inventory_enabled(self.company):
stock_rbnb = self.get_company_default("stock_received_but_not_billed")
landed_cost_entries = get_item_account_wise_additional_cost(self.name)
expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
warehouse_with_no_account = []
stock_items = self.get_stock_items()
provisional_accounting_for_non_stock_items = \
cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items'))
for d in self.get("items"):
if d.item_code in stock_items and flt(d.valuation_rate) and flt(d.qty):
if warehouse_account.get(d.warehouse):
stock_value_diff = frappe.db.get_value("Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": self.name,
"voucher_detail_no": d.name, "warehouse": d.warehouse}, "stock_value_difference")
if not stock_value_diff:
continue
"voucher_detail_no": d.name, "warehouse": d.warehouse, "is_cancelled": 0}, "stock_value_difference")
warehouse_account_name = warehouse_account[d.warehouse]["account"]
warehouse_account_currency = warehouse_account[d.warehouse]["account_currency"]
@@ -386,43 +396,58 @@ class PurchaseReceipt(BuyingController):
elif d.warehouse not in warehouse_with_no_account or \
d.rejected_warehouse not in warehouse_with_no_account:
warehouse_with_no_account.append(d.warehouse)
elif d.item_code not in stock_items and not d.is_fixed_asset and flt(d.qty) and auto_accounting_for_non_stock_items:
service_received_but_not_billed_account = self.get_company_default("service_received_but_not_billed")
credit_currency = get_account_currency(service_received_but_not_billed_account)
debit_currency = get_account_currency(d.expense_account)
remarks = self.get("remarks") or _("Accounting Entry for Service")
self.add_gl_entry(
gl_entries=gl_entries,
account=service_received_but_not_billed_account,
cost_center=d.cost_center,
debit=0.0,
credit=d.amount,
remarks=remarks,
against_account=d.expense_account,
account_currency=credit_currency,
project=d.project,
voucher_detail_no=d.name, item=d)
self.add_gl_entry(
gl_entries=gl_entries,
account=d.expense_account,
cost_center=d.cost_center,
debit=d.amount,
credit=0.0,
remarks=remarks,
against_account=service_received_but_not_billed_account,
account_currency = debit_currency,
project=d.project,
voucher_detail_no=d.name,
item=d)
elif d.item_code not in stock_items and not d.is_fixed_asset and flt(d.qty) and provisional_accounting_for_non_stock_items:
self.add_provisional_gl_entry(d, gl_entries, self.posting_date)
if warehouse_with_no_account:
frappe.msgprint(_("No accounting entries for the following warehouses") + ": \n" +
"\n".join(warehouse_with_no_account))
def add_provisional_gl_entry(self, item, gl_entries, posting_date, reverse=0):
provisional_expense_account = self.get('provisional_expense_account')
credit_currency = get_account_currency(provisional_expense_account)
debit_currency = get_account_currency(item.expense_account)
expense_account = item.expense_account
remarks = self.get("remarks") or _("Accounting Entry for Service")
multiplication_factor = 1
if reverse:
multiplication_factor = -1
expense_account = frappe.db.get_value('Purchase Receipt Item', {'name': item.get('pr_detail')}, ['expense_account'])
self.add_gl_entry(
gl_entries=gl_entries,
account=provisional_expense_account,
cost_center=item.cost_center,
debit=0.0,
credit=multiplication_factor * item.amount,
remarks=remarks,
against_account=expense_account,
account_currency=credit_currency,
project=item.project,
voucher_detail_no=item.name,
item=item,
posting_date=posting_date)
self.add_gl_entry(
gl_entries=gl_entries,
account=expense_account,
cost_center=item.cost_center,
debit=multiplication_factor * item.amount,
credit=0.0,
remarks=remarks,
against_account=provisional_expense_account,
account_currency = debit_currency,
project=item.project,
voucher_detail_no=item.name,
item=item,
posting_date=posting_date)
def make_tax_gl_entries(self, gl_entries):
expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
if erpnext.is_perpetual_inventory_enabled(self.company):
expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in self.get('items')])
# Cost center-wise amount breakup for other charges included for valuation
valuation_tax = {}
@@ -479,7 +504,8 @@ class PurchaseReceipt(BuyingController):
def add_gl_entry(self, gl_entries, account, cost_center, debit, credit, remarks, against_account,
debit_in_account_currency=None, credit_in_account_currency=None, account_currency=None,
project=None, voucher_detail_no=None, item=None):
project=None, voucher_detail_no=None, item=None, posting_date=None):
gl_entry = {
"account": account,
"cost_center": cost_center,
@@ -498,6 +524,9 @@ class PurchaseReceipt(BuyingController):
if credit_in_account_currency:
gl_entry.update({"credit_in_account_currency": credit_in_account_currency})
if posting_date:
gl_entry.update({"posting_date": posting_date})
gl_entries.append(self.get_gl_dict(gl_entry, item=item))
def get_asset_gl_entry(self, gl_entries):
@@ -526,6 +555,7 @@ class PurchaseReceipt(BuyingController):
# debit cwip account
debit_in_account_currency = (base_asset_amount
if cwip_account_currency == self.company_currency else asset_amount)
self.add_gl_entry(
gl_entries=gl_entries,
account=cwip_account,
@@ -541,6 +571,7 @@ class PurchaseReceipt(BuyingController):
# credit arbnb account
credit_in_account_currency = (base_asset_amount
if asset_rbnb_currency == self.company_currency else asset_amount)
self.add_gl_entry(
gl_entries=gl_entries,
account=arbnb_account,

View File

@@ -4,6 +4,7 @@
import json
import unittest
from collections import defaultdict
import frappe
from frappe.utils import add_days, cint, cstr, flt, today
@@ -17,7 +18,7 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchas
from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
from erpnext.tests.utils import ERPNextTestCase
from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestPurchaseReceipt(ERPNextTestCase):
@@ -161,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(
@@ -1331,58 +1341,6 @@ class TestPurchaseReceipt(ERPNextTestCase):
self.assertEqual(pr.status, "To Bill")
self.assertAlmostEqual(pr.per_billed, 50.0, places=2)
def test_service_item_purchase_with_perpetual_inventory(self):
company = '_Test Company with perpetual inventory'
service_item = '_Test Non Stock Item'
before_test_value = frappe.db.get_value(
'Company', company, 'enable_perpetual_inventory_for_non_stock_items'
)
frappe.db.set_value(
'Company', company,
'enable_perpetual_inventory_for_non_stock_items', 1
)
srbnb_account = 'Stock Received But Not Billed - TCP1'
frappe.db.set_value(
'Company', company,
'service_received_but_not_billed', srbnb_account
)
pr = make_purchase_receipt(
company=company, item=service_item,
warehouse='Finished Goods - TCP1', do_not_save=1
)
item_row_with_diff_rate = frappe.copy_doc(pr.items[0])
item_row_with_diff_rate.rate = 100
pr.append('items', item_row_with_diff_rate)
pr.save()
pr.submit()
item_one_gl_entry = frappe.db.get_all("GL Entry", {
'voucher_type': pr.doctype,
'voucher_no': pr.name,
'account': srbnb_account,
'voucher_detail_no': pr.items[0].name
}, pluck="name")
item_two_gl_entry = frappe.db.get_all("GL Entry", {
'voucher_type': pr.doctype,
'voucher_no': pr.name,
'account': srbnb_account,
'voucher_detail_no': pr.items[1].name
}, pluck="name")
# check if the entries are not merged into one
# seperate entries should be made since voucher_detail_no is different
self.assertEqual(len(item_one_gl_entry), 1)
self.assertEqual(len(item_two_gl_entry), 1)
frappe.db.set_value(
'Company', company,
'enable_perpetual_inventory_for_non_stock_items', before_test_value
)
def test_payment_terms_are_fetched_when_creating_purchase_invoice(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
create_payment_terms_template,
@@ -1419,6 +1377,36 @@ class TestPurchaseReceipt(ERPNextTestCase):
automatically_fetch_payment_terms(enable=0)
@change_settings("Stock Settings", {"allow_negative_stock": 1})
def test_neg_to_positive(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item_code = "_TestNegToPosItem"
warehouse = "Stores - TCP1"
company = "_Test Company with perpetual inventory"
account = "Stock Received But Not Billed - TCP1"
make_item(item_code)
se = make_stock_entry(item_code=item_code, from_warehouse=warehouse, qty=50, do_not_save=True, rate=0)
se.items[0].allow_zero_valuation_rate = 1
se.save()
se.submit()
pr = make_purchase_receipt(
qty=50,
rate=1,
item_code=item_code,
warehouse=warehouse,
get_taxes_and_charges=True,
company=company,
)
gles = get_gl_entries(pr.doctype, pr.name)
for gle in gles:
if gle.account == account:
self.assertEqual(gle.credit, 50)
def get_sl_entries(voucher_type, voucher_no):
return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference
from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s

View File

@@ -976,7 +976,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2021-11-15 15:46:10.591600",
"modified": "2022-02-01 11:32:27.980524",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",
@@ -985,5 +985,6 @@
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC"
"sort_order": "DESC",
"states": []
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"autoname": "REPOST-ITEM-VAL-.######",
"creation": "2020-10-22 22:27:07.742161",
"creation": "2022-01-11 15:03:38.273179",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@@ -129,7 +129,7 @@
"reqd": 1
},
{
"default": "0",
"default": "1",
"fieldname": "allow_negative_stock",
"fieldtype": "Check",
"label": "Allow Negative Stock"
@@ -177,7 +177,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2021-11-24 02:18:10.524560",
"modified": "2022-01-18 10:57:33.450907",
"modified_by": "Administrator",
"module": "Stock",
"name": "Repost Item Valuation",
@@ -227,5 +227,6 @@
}
],
"sort_field": "modified",
"sort_order": "DESC"
}
"sort_order": "DESC",
"states": []
}

View File

@@ -13,7 +13,7 @@ from erpnext.accounts.utils import (
check_if_stock_and_account_balance_synced,
update_gl_entries_after,
)
from erpnext.stock.stock_ledger import repost_future_sle
from erpnext.stock.stock_ledger import get_items_to_be_repost, repost_future_sle
class RepostItemValuation(Document):
@@ -27,8 +27,7 @@ class RepostItemValuation(Document):
self.item_code = None
self.warehouse = None
self.allow_negative_stock = self.allow_negative_stock or \
cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
self.allow_negative_stock = 1
def set_company(self):
if self.based_on == "Transaction":
@@ -139,13 +138,20 @@ def repost_gl_entries(doc):
if doc.based_on == 'Transaction':
ref_doc = frappe.get_doc(doc.voucher_type, doc.voucher_no)
items, warehouses = ref_doc.get_items_and_warehouses()
doc_items, doc_warehouses = ref_doc.get_items_and_warehouses()
sles = get_items_to_be_repost(doc.voucher_type, doc.voucher_no)
sle_items = [sle.item_code for sle in sles]
sle_warehouse = [sle.warehouse for sle in sles]
items = list(set(doc_items).union(set(sle_items)))
warehouses = list(set(doc_warehouses).union(set(sle_warehouse)))
else:
items = [doc.item_code]
warehouses = [doc.warehouse]
update_gl_entries_after(doc.posting_date, doc.posting_time,
warehouses, items, company=doc.company)
for_warehouses=warehouses, for_items=items, company=doc.company)
def notify_error_to_stock_managers(doc, traceback):
recipients = get_users_with_role("Stock Manager")

View File

@@ -421,10 +421,16 @@ def update_serial_nos(sle, item_det):
def get_auto_serial_nos(serial_no_series, qty):
serial_nos = []
for i in range(cint(qty)):
serial_nos.append(make_autoname(serial_no_series, "Serial No"))
serial_nos.append(get_new_serial_number(serial_no_series))
return "\n".join(serial_nos)
def get_new_serial_number(series):
sr_no = make_autoname(series, "Serial No")
if frappe.db.exists("Serial No", sr_no):
sr_no = get_new_serial_number(series)
return sr_no
def auto_make_serial_nos(args):
serial_nos = get_serial_nos(args.get('serial_no'))
created_numbers = []
@@ -478,6 +484,13 @@ def get_serial_nos(serial_no):
return [s.strip() for s in cstr(serial_no).strip().upper().replace(',', '\n').split('\n')
if s.strip()]
def clean_serial_no_string(serial_no: str) -> str:
if not serial_no:
return ""
serial_no_list = get_serial_nos(serial_no)
return "\n".join(serial_no_list)
def update_args_for_serial_no(serial_no_doc, serial_no, args, is_new=False):
for field in ["item_code", "work_order", "company", "batch_no", "supplier", "location"]:
if args.get(field):

View File

@@ -8,8 +8,10 @@
import frappe
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
@@ -21,6 +23,10 @@ from erpnext.tests.utils import ERPNextTestCase
class TestSerialNo(ERPNextTestCase):
def tearDown(self):
frappe.db.rollback()
def test_cannot_create_direct(self):
frappe.delete_doc_if_exists("Serial No", "_TCSER0001")
@@ -176,6 +182,24 @@ class TestSerialNo(ERPNextTestCase):
self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC")
self.assertEqual(sn_doc.purchase_document_no, se.name)
def test_auto_creation_of_serial_no(self):
"""
Test if auto created Serial No excludes existing serial numbers
"""
item_code = make_item("_Test Auto Serial Item ", {
"has_serial_no": 1,
"serial_no_series": "XYZ.###"
}).item_code
# Reserve XYZ005
pr_1 = make_purchase_receipt(item_code=item_code, qty=1, serial_no="XYZ005")
# XYZ005 is already used and will throw an error if used again
pr_2 = make_purchase_receipt(item_code=item_code, qty=10)
self.assertEqual(get_serial_nos(pr_1.get("items")[0].serial_no)[0], "XYZ005")
for serial_no in get_serial_nos(pr_2.get("items")[0].serial_no):
self.assertNotEqual(serial_no, "XYZ005")
def test_serial_no_sanitation(self):
"Test if Serial No input is sanitised before entering the DB."
item_code = "_Test Serialized Item"
@@ -192,7 +216,28 @@ class TestSerialNo(ERPNextTestCase):
self.assertEqual(se.get("items")[0].serial_no, "_TS1\n_TS2\n_TS3\n_TS4 - 2021")
frappe.db.rollback()
def test_correct_serial_no_incoming_rate(self):
""" Check correct consumption rate based on serial no record.
"""
item_code = "_Test Serialized Item"
warehouse = "_Test Warehouse - _TC"
serial_nos = ["LOWVALUATION", "HIGHVALUATION"]
in1 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=1, rate=42,
serial_no=serial_nos[0])
in2 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=1, rate=113,
serial_no=serial_nos[1])
out = create_delivery_note(item_code=item_code, qty=1, serial_no=serial_nos[0], do_not_submit=True)
# change serial no
out.items[0].serial_no = serial_nos[1]
out.save()
out.submit()
value_diff = frappe.db.get_value("Stock Ledger Entry",
{"voucher_no": out.name, "voucher_type": "Delivery Note"},
"stock_value_difference"
)
self.assertEqual(value_diff, -113)
def tearDown(self):
frappe.db.rollback()

View File

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

View File

@@ -8,7 +8,6 @@
"engine": "InnoDB",
"field_order": [
"items_section",
"title",
"naming_series",
"stock_entry_type",
"outgoing_stock_entry",
@@ -83,14 +82,6 @@
"fieldtype": "Section Break",
"oldfieldtype": "Section Break"
},
{
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title",
"no_copy": 1,
"print_hide": 1
},
{
"fieldname": "naming_series",
"fieldtype": "Select",
@@ -353,9 +344,9 @@
},
{
"fieldname": "scan_barcode",
"options": "Barcode",
"fieldtype": "Data",
"label": "Scan Barcode"
"label": "Scan Barcode",
"options": "Barcode"
},
{
"allow_bulk_edit": 1,
@@ -628,10 +619,11 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2021-08-20 19:19:31.514846",
"modified": "2022-02-07 12:55:14.614077",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -698,6 +690,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "title",
"states": [],
"title_field": "stock_entry_type",
"track_changes": 1
}

View File

@@ -8,6 +8,7 @@ from collections import defaultdict
import frappe
from frappe import _
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import cint, comma_or, cstr, flt, format_time, formatdate, getdate, nowdate
from six import iteritems, itervalues, string_types
@@ -76,7 +77,6 @@ class StockEntry(StockController):
self.validate_posting_time()
self.validate_purpose()
self.set_title()
self.validate_item()
self.validate_customer_provided_item()
self.validate_qty()
@@ -86,8 +86,11 @@ class StockEntry(StockController):
self.validate_warehouse()
self.validate_work_order()
self.validate_bom()
self.mark_finished_and_scrap_items()
self.validate_finished_goods()
if self.purpose in ("Manufacture", "Repack"):
self.mark_finished_and_scrap_items()
self.validate_finished_goods()
self.validate_with_material_request()
self.validate_batch()
self.validate_inspection()
@@ -110,8 +113,12 @@ class StockEntry(StockController):
self.set_actual_qty()
self.calculate_rate_and_amount()
self.validate_putaway_capacity()
self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
self.reset_default_field_value("to_warehouse", "items", "t_warehouse")
if not self.get("purpose") == "Manufacture":
# ignore scrap item wh difference and empty source/target wh
# in Manufacture Entry
self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
self.reset_default_field_value("to_warehouse", "items", "t_warehouse")
def on_submit(self):
self.update_stock_ledger()
@@ -702,26 +709,25 @@ class StockEntry(StockController):
validate_bom_no(item_code, d.bom_no)
def mark_finished_and_scrap_items(self):
if self.purpose in ("Repack", "Manufacture"):
if any([d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)]):
return
if any([d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)]):
return
finished_item = self.get_finished_item()
finished_item = self.get_finished_item()
if not finished_item and self.purpose == "Manufacture":
# In case of independent Manufacture entry, don't auto set
# user must decide and set
return
if not finished_item and self.purpose == "Manufacture":
# In case of independent Manufacture entry, don't auto set
# user must decide and set
return
for d in self.items:
if d.t_warehouse and not d.s_warehouse:
if self.purpose=="Repack" or d.item_code == finished_item:
d.is_finished_item = 1
else:
d.is_scrap_item = 1
for d in self.items:
if d.t_warehouse and not d.s_warehouse:
if self.purpose=="Repack" or d.item_code == finished_item:
d.is_finished_item = 1
else:
d.is_finished_item = 0
d.is_scrap_item = 0
d.is_scrap_item = 1
else:
d.is_finished_item = 0
d.is_scrap_item = 0
def get_finished_item(self):
finished_item = None
@@ -734,9 +740,9 @@ class StockEntry(StockController):
def validate_finished_goods(self):
"""
1. Check if FG exists
2. Check if Multiple FG Items are present
3. Check FG Item and Qty against WO if present
1. Check if FG exists (mfg, repack)
2. Check if Multiple FG Items are present (mfg)
3. Check FG Item and Qty against WO if present (mfg)
"""
production_item, wo_qty, finished_items = None, 0, []
@@ -749,8 +755,9 @@ class StockEntry(StockController):
for d in self.get('items'):
if d.is_finished_item:
if not self.work_order:
# Independent MFG Entry/ Repack Entry, no WO to match against
finished_items.append(d.item_code)
continue # Independent Manufacture Entry, no WO to match against
continue
if d.item_code != production_item:
frappe.throw(_("Finished Item {0} does not match with Work Order {1}")
@@ -763,19 +770,17 @@ class StockEntry(StockController):
finished_items.append(d.item_code)
if len(set(finished_items)) > 1:
if not finished_items:
frappe.throw(
msg=_("Multiple items cannot be marked as finished item"),
title=_("Note"),
exc=FinishedGoodError
msg=_("There must be atleast 1 Finished Good in this Stock Entry").format(self.name),
title=_("Missing Finished Good"), exc=FinishedGoodError
)
if self.purpose == "Manufacture":
if not finished_items:
if len(set(finished_items)) > 1:
frappe.throw(
msg=_("There must be atleast 1 Finished Good in this Stock Entry").format(self.name),
title=_("Missing Finished Good"),
exc=FinishedGoodError
msg=_("Multiple items cannot be marked as finished item"),
title=_("Note"), exc=FinishedGoodError
)
allowance_percentage = flt(
@@ -1111,7 +1116,7 @@ class StockEntry(StockController):
self.set_actual_qty()
self.update_items_for_process_loss()
self.validate_customer_provided_item()
self.calculate_rate_and_amount()
self.calculate_rate_and_amount(raise_error_if_no_rate=False)
def set_scrap_items(self):
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]:
@@ -1276,22 +1281,29 @@ class StockEntry(StockController):
if not self.pro_doc:
self.set_work_order_details()
scrap_items = frappe.db.sql('''
SELECT
JCSI.item_code, JCSI.item_name, SUM(JCSI.stock_qty) as stock_qty, JCSI.stock_uom, JCSI.description
FROM
`tabJob Card` JC, `tabJob Card Scrap Item` JCSI
WHERE
JCSI.parent = JC.name AND JC.docstatus = 1
AND JCSI.item_code IS NOT NULL AND JC.work_order = %s
GROUP BY
JCSI.item_code
''', self.work_order, as_dict=1)
pending_qty = flt(self.pro_doc.qty) - flt(self.pro_doc.produced_qty)
if pending_qty <=0:
if not self.pro_doc.operations:
return []
job_card = frappe.qb.DocType('Job Card')
job_card_scrap_item = frappe.qb.DocType('Job Card Scrap Item')
scrap_items = (
frappe.qb.from_(job_card)
.select(
Sum(job_card_scrap_item.stock_qty).as_('stock_qty'),
job_card_scrap_item.item_code, job_card_scrap_item.item_name,
job_card_scrap_item.description, job_card_scrap_item.stock_uom)
.join(job_card_scrap_item)
.on(job_card_scrap_item.parent == job_card.name)
.where(
(job_card_scrap_item.item_code.isnotnull())
& (job_card.work_order == self.work_order)
& (job_card.docstatus == 1))
.groupby(job_card_scrap_item.item_code)
).run(as_dict=1)
pending_qty = flt(self.get_completed_job_card_qty()) - flt(self.pro_doc.produced_qty)
used_scrap_items = self.get_used_scrap_items()
for row in scrap_items:
row.stock_qty -= flt(used_scrap_items.get(row.item_code))
@@ -1305,6 +1317,9 @@ class StockEntry(StockController):
return scrap_items
def get_completed_job_card_qty(self):
return flt(min([d.completed_qty for d in self.pro_doc.operations]))
def get_used_scrap_items(self):
used_scrap_items = defaultdict(float)
data = frappe.get_all(
@@ -1430,14 +1445,15 @@ class StockEntry(StockController):
qty = req_qty_each * flt(self.fg_completed_qty)
elif backflushed_materials.get(item.item_code):
precision = frappe.get_precision("Stock Entry Detail", "qty")
for d in backflushed_materials.get(item.item_code):
if d.get(item.warehouse):
if d.get(item.warehouse) > 0:
if (qty > req_qty):
qty = (qty/trans_qty) * flt(self.fg_completed_qty)
qty = ((flt(qty, precision) - flt(d.get(item.warehouse), precision))
/ (flt(trans_qty, precision) - flt(produced_qty, precision))
) * flt(self.fg_completed_qty)
if consumed_qty and frappe.db.get_single_value("Manufacturing Settings",
"material_consumption"):
qty -= consumed_qty
d[item.warehouse] -= qty
if cint(frappe.get_cached_value('UOM', item.stock_uom, 'must_be_whole_number')):
qty = frappe.utils.ceil(qty)
@@ -1657,6 +1673,8 @@ class StockEntry(StockController):
for d in self.get("items"):
item_code = d.get('original_item') or d.get('item_code')
reserve_warehouse = item_wh.get(item_code)
if not (reserve_warehouse and item_code):
continue
stock_bin = get_bin(item_code, reserve_warehouse)
stock_bin.update_reserved_qty_for_sub_contracting()
@@ -1818,14 +1836,6 @@ class StockEntry(StockController):
return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos)))
def set_title(self):
if frappe.flags.in_import and self.title:
# Allow updating title during data import/update
return
self.title = self.purpose
@frappe.whitelist()
def move_sample_to_retention_warehouse(company, items):
if isinstance(items, string_types):

View File

@@ -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")
@@ -227,9 +228,47 @@ class TestStockEntry(ERPNextTestCase):
mtn.cancel()
def test_repack_no_change_in_valuation(self):
company = frappe.db.get_value('Warehouse', '_Test Warehouse - _TC', 'company')
def test_repack_multiple_fg(self):
"Test `is_finished_item` for one item repacked into two items."
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=100, basic_rate=100)
repack = frappe.copy_doc(test_records[3])
repack.posting_date = nowdate()
repack.posting_time = nowtime()
repack.items[0].qty = 100.0
repack.items[0].transfer_qty = 100.0
repack.items[1].qty = 50.0
repack.append("items", {
"conversion_factor": 1.0,
"cost_center": "_Test Cost Center - _TC",
"doctype": "Stock Entry Detail",
"expense_account": "Stock Adjustment - _TC",
"basic_rate": 150,
"item_code": "_Test Item 2",
"parentfield": "items",
"qty": 50.0,
"stock_uom": "_Test UOM",
"t_warehouse": "_Test Warehouse - _TC",
"transfer_qty": 50.0,
"uom": "_Test UOM"
})
repack.set_stock_entry_type()
repack.insert()
self.assertEqual(repack.items[1].is_finished_item, 1)
self.assertEqual(repack.items[2].is_finished_item, 1)
repack.items[1].is_finished_item = 0
repack.items[2].is_finished_item = 0
# must raise error if 0 fg in repack entry
self.assertRaises(FinishedGoodError, repack.validate_finished_goods)
repack.delete() # teardown
def test_repack_no_change_in_valuation(self):
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=50, basic_rate=100)
make_stock_entry(item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC",
qty=50, basic_rate=100)
@@ -528,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")
@@ -652,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",
@@ -814,6 +856,34 @@ class TestStockEntry(ERPNextTestCase):
self.assertEqual(se.get("items")[0].allow_zero_valuation_rate, 1)
self.assertEqual(se.get("items")[0].amount, 0)
def test_zero_incoming_rate(self):
""" Make sure incoming rate of 0 is allowed while consuming.
qty | rate | valuation rate
1 | 100 | 100
1 | 0 | 50
-1 | 100 | 0
-1 | 0 <--- assert this
"""
item_code = "_TestZeroVal"
warehouse = "_Test Warehouse - _TC"
create_item('_TestZeroVal')
_receipt = make_stock_entry(item_code=item_code, qty=1, to_warehouse=warehouse, rate=100)
receipt2 = make_stock_entry(item_code=item_code, qty=1, to_warehouse=warehouse, rate=0, do_not_save=True)
receipt2.items[0].allow_zero_valuation_rate = 1
receipt2.save()
receipt2.submit()
issue = make_stock_entry(item_code=item_code, qty=1, from_warehouse=warehouse)
value_diff = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": issue.name, "voucher_type": "Stock Entry"}, "stock_value_difference")
self.assertEqual(value_diff, -100)
issue2 = make_stock_entry(item_code=item_code, qty=1, from_warehouse=warehouse)
value_diff = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": issue2.name, "voucher_type": "Stock Entry"}, "stock_value_difference")
self.assertEqual(value_diff, 0)
def test_gle_for_opening_stock_entry(self):
mr = make_stock_entry(item_code="_Test Item", target="Stores - TCP1",
company="_Test Company with perpetual inventory", qty=50, basic_rate=100,
@@ -1035,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])

View File

@@ -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": []
}

View File

@@ -1,11 +1,16 @@
# 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
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.delivery_note.test_delivery_note import (
create_delivery_note,
create_return_delivery_note,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher,
@@ -29,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"
@@ -232,8 +258,7 @@ class TestStockLedgerEntry(ERPNextTestCase):
self.assertEqual(outgoing_rate, 100)
# Return Entry: Qty = -2, Rate = 150
return_dn = create_delivery_note(is_return=1, return_against=dn.name, item_code=bundled_item, qty=-2, rate=150,
company=company, warehouse="Stores - _TC", expense_account="Cost of Goods Sold - _TC", cost_center="Main - _TC")
return_dn = create_return_delivery_note(source_name=dn.name, rate=150, qty=-2)
# check incoming rate for Return entry
incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry",
@@ -347,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)

View File

@@ -18,7 +18,6 @@
"items",
"section_break_9",
"expense_account",
"reconciliation_json",
"column_break_13",
"difference_amount",
"amended_from",
@@ -111,15 +110,6 @@
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "reconciliation_json",
"fieldtype": "Long Text",
"hidden": 1,
"label": "Reconciliation JSON",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "column_break_13",
"fieldtype": "Column Break"
@@ -155,7 +145,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
"modified": "2021-11-30 01:33:51.437194",
"modified": "2022-02-06 14:28:19.043905",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation",
@@ -178,5 +168,6 @@
"search_fields": "posting_date",
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC"
"sort_order": "DESC",
"states": []
}

View File

@@ -6,7 +6,7 @@
import frappe
from frappe.utils import add_days, flt, nowdate, nowtime, random_string
from frappe.utils import add_days, cstr, flt, nowdate, nowtime, random_string
from erpnext.accounts.utils import get_stock_and_account_balance
from erpnext.stock.doctype.item.test_item import create_item
@@ -25,8 +25,8 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestStockReconciliation(ERPNextTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
create_batch_or_serial_no_items()
super().setUpClass()
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
def tearDown(self):
@@ -439,8 +439,8 @@ class TestStockReconciliation(ERPNextTestCase):
self.assertRaises(frappe.ValidationError, sr.submit)
def test_serial_no_cancellation(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item = create_item("Stock-Reco-Serial-Item-9", is_stock_item=1)
if not item.has_serial_no:
item.has_serial_no = 1
@@ -466,6 +466,31 @@ class TestStockReconciliation(ERPNextTestCase):
self.assertEqual(len(active_sr_no), 10)
def test_serial_no_creation_and_inactivation(self):
item = create_item("_TestItemCreatedWithStockReco", is_stock_item=1)
if not item.has_serial_no:
item.has_serial_no = 1
item.save()
item_code = item.name
warehouse = "_Test Warehouse - _TC"
sr = create_stock_reconciliation(item_code=item.name, warehouse=warehouse,
serial_no="SR-CREATED-SR-NO", qty=1, do_not_submit=True, rate=100)
sr.save()
self.assertEqual(cstr(sr.items[0].current_serial_no), "")
sr.submit()
active_sr_no = frappe.get_all("Serial No",
filters={"item_code": item_code, "warehouse": warehouse, "status": "Active"})
self.assertEqual(len(active_sr_no), 1)
sr.cancel()
active_sr_no = frappe.get_all("Serial No",
filters={"item_code": item_code, "warehouse": warehouse, "status": "Active"})
self.assertEqual(len(active_sr_no), 0)
def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1)
if not batch_item_doc.has_batch_no:

View File

@@ -244,7 +244,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2021-12-03 04:40:06.414630",
"modified": "2022-03-01 02:37:48.034944",
"modified_by": "Administrator",
"module": "Stock",
"name": "Warehouse",
@@ -301,5 +301,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "warehouse_name"
"states": [],
"title_field": "warehouse_name",
"track_changes": 1
}

View File

@@ -344,6 +344,7 @@ def get_basic_details(args, item, overwrite_warehouse=True):
args.conversion_factor = out.conversion_factor
out.stock_qty = out.qty * out.conversion_factor
args.stock_qty = out.stock_qty
# calculate last purchase rate
if args.get('doctype') in purchase_doctypes:
@@ -359,7 +360,7 @@ def get_basic_details(args, item, overwrite_warehouse=True):
if not out[d[1]]:
out[d[1]] = frappe.get_cached_value('Company', args.company, d[2]) if d[2] else None
for fieldname in ("item_name", "item_group", "barcodes", "brand", "stock_uom"):
for fieldname in ("item_name", "item_group", "brand", "stock_uom"):
out[fieldname] = item.get(fieldname)
if args.get("manufacturer"):

View File

@@ -55,7 +55,8 @@ def get_stock_ledger_entries(filters):
return frappe.db.sql("""select item_code, batch_no, warehouse,
posting_date, actual_qty
from `tabStock Ledger Entry`
where docstatus < 2 and ifnull(batch_no, '') != '' %s order by item_code, warehouse""" %
where is_cancelled = 0
and docstatus < 2 and ifnull(batch_no, '') != '' %s order by item_code, warehouse""" %
conditions, as_dict=1)
def get_item_warehouse_batch_map(filters, float_precision):

View File

@@ -91,7 +91,7 @@ def get_stock_value_difference_list(filtered_entries: FilteredEntries) -> SVDLis
voucher_nos = [fe.get('voucher_no') for fe in filtered_entries]
svd_list = frappe.get_list(
'Stock Ledger Entry', fields=['item_code','stock_value_difference'],
filters=[('voucher_no', 'in', voucher_nos)]
filters=[('voucher_no', 'in', voucher_nos), ("is_cancelled", "=", 0)]
)
assign_item_groups_to_svd_list(svd_list)
return svd_list

View File

@@ -76,6 +76,7 @@ def get_consumed_items(condition):
on sle.voucher_no = se.name
where
actual_qty < 0
and is_cancelled = 0
and voucher_type not in ('Delivery Note', 'Sales Invoice')
%s
group by item_code""" % condition, as_dict=1)

View File

@@ -12,6 +12,7 @@ from frappe.utils import cint, date_diff, flt
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
Filters = frappe._dict
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
def execute(filters: Filters = None) -> Tuple:
to_date = filters["to_date"]
@@ -48,10 +49,13 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li
if filters.get("show_warehouse_wise_stock"):
row.append(details.warehouse)
row.extend([item_dict.get("total_qty"), average_age,
row.extend([
flt(item_dict.get("total_qty"), precision),
average_age,
range1, range2, range3, above_range3,
earliest_age, latest_age,
details.stock_uom])
details.stock_uom
])
data.append(row)
@@ -79,13 +83,13 @@ def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: D
qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0
if age <= filters.range1:
range1 += qty
range1 = flt(range1 + qty, precision)
elif age <= filters.range2:
range2 += qty
range2 = flt(range2 + qty, precision)
elif age <= filters.range3:
range3 += qty
range3 = flt(range3 + qty, precision)
else:
above_range3 += qty
above_range3 = flt(above_range3 + qty, precision)
return range1, range2, range3, above_range3
@@ -252,6 +256,7 @@ class FIFOSlots:
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
if d.voucher_type == "Stock Reconciliation":
# get difference in qty shift as actual qty
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
@@ -264,12 +269,16 @@ class FIFOSlots:
self.__update_balances(d, key)
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
self.item_details = self.__aggregate_details_by_item(self.item_details)
return self.item_details
def __init_key_stores(self, row: Dict) -> Tuple:
"Initialise keys and FIFO Queue."
key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name
key = (row.name, row.warehouse)
self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
fifo_queue = self.item_details[key]["fifo_queue"]
@@ -281,14 +290,16 @@ class FIFOSlots:
def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
"Update FIFO Queue on inward stock."
if self.transferred_item_details.get(transfer_key):
transfer_data = self.transferred_item_details.get(transfer_key)
if transfer_data:
# inward/outward from same voucher, item & warehouse
slot = self.transferred_item_details[transfer_key].pop(0)
fifo_queue.append(slot)
# eg: Repack with same item, Stock reco for batch item
# consume transfer data and add stock to fifo queue
self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row)
else:
if not serial_nos:
if fifo_queue and flt(fifo_queue[0][0]) < 0:
# neutralize negative stock by adding positive stock
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
# neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(row.actual_qty)
fifo_queue[0][1] = row.posting_date
else:
@@ -319,7 +330,7 @@ class FIFOSlots:
elif not fifo_queue:
# negative stock, no balance but qty yet to consume
fifo_queue.append([-(qty_to_pop), row.posting_date])
self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date])
self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date])
qty_to_pop = 0
else:
# qty to pop < slot qty, ample balance
@@ -328,6 +339,33 @@ class FIFOSlots:
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
qty_to_pop = 0
def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict):
"Add previously removed stock back to FIFO Queue."
transfer_qty_to_pop = flt(row.actual_qty)
def add_to_fifo_queue(slot):
if fifo_queue and flt(fifo_queue[0][0]) <= 0:
# neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(slot[0])
fifo_queue[0][1] = slot[1]
else:
fifo_queue.append(slot)
while transfer_qty_to_pop:
if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop:
# bucket qty is not enough, consume whole
transfer_qty_to_pop -= transfer_data[0][0]
add_to_fifo_queue(transfer_data.pop(0))
elif not transfer_data:
# transfer bucket is empty, extra incoming qty
add_to_fifo_queue([transfer_qty_to_pop, row.posting_date])
transfer_qty_to_pop = 0
else:
# ample bucket qty to consume
transfer_data[0][0] -= transfer_qty_to_pop
add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]])
transfer_qty_to_pop = 0
def __update_balances(self, row: Dict, key: Union[Tuple, str]):
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
@@ -338,6 +376,27 @@ class FIFOSlots:
self.item_details[key]["has_serial_no"] = row.has_serial_no
def __aggregate_details_by_item(self, wh_wise_data: Dict) -> Dict:
"Aggregate Item-Wh wise data into single Item entry."
item_aggregated_data = {}
for key,row in wh_wise_data.items():
item = key[0]
if not item_aggregated_data.get(item):
item_aggregated_data.setdefault(item, {
"details": frappe._dict(),
"fifo_queue": [],
"qty_after_transaction": 0.0,
"total_qty": 0.0
})
item_row = item_aggregated_data.get(item)
item_row["details"].update(row["details"])
item_row["fifo_queue"].extend(row["fifo_queue"])
item_row["qty_after_transaction"] += flt(row["qty_after_transaction"])
item_row["total_qty"] += flt(row["total_qty"])
item_row["has_serial_no"] = row["has_serial_no"]
return item_aggregated_data
def __get_stock_ledger_entries(self) -> List[Dict]:
sle = frappe.qb.DocType("Stock Ledger Entry")
item = self.__get_item_query() # used as derived table in sle query

View File

@@ -15,6 +15,7 @@ Here, the balance qty is 70.
50 qty is (today-the 1st) days old
20 qty is (today-the 2nd) days old
> Note: We generate FIFO slots warehouse wise as stock reconciliations from different warehouses can cause incorrect values.
### Calculation of FIFO Slots
#### Case 1: Outward from sufficient balance qty
@@ -70,4 +71,39 @@ Date | Qty | Queue
2nd | -60 | [[-10, 1-12-2021]]
3rd | +5 | [[-5, 3-12-2021]]
4th | +10 | [[5, 4-12-2021]]
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
### Concept of Transfer Qty Bucket
In the case of **Repack**, Quantity that comes in, isn't really incoming. It is just new stock repurposed from old stock, due to incoming-outgoing of the same warehouse.
Here, stock is consumed from the FIFO Queue. It is then re-added back to the queue.
While adding stock back to the queue we need to know how much to add.
For this we need to keep track of how much was previously consumed.
Hence we use **Transfer Qty Bucket**.
While re-adding stock, we try to add buckets that were consumed earlier (date intact), to maintain correctness.
#### Case 1: Same Item-Warehouse in Repack
Eg:
-------------------------------------------------------------------------------------
Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
-------------------------------------------------------------------------------------
1st | +500 | PR | [[500, 1-12-2021]] |
2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
2nd | +50 | Repack | [[450, 1-12-2021], [50, 1-12-2021]] | []
- The balance at the end is restored back to 500
- However, the initial 500 qty bucket is now split into 450 and 50, with the same date
- The net effect is the same as that before the Repack
#### Case 2: Same Item-Warehouse in Repack with Split Consumption rows
Eg:
-------------------------------------------------------------------------------------
Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
-------------------------------------------------------------------------------------
1st | +500 | PR | [[500, 1-12-2021]] |
2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
2nd | -50 | Repack | [[400, 1-12-2021]] | [[50, 1-12-2021],
- | | | |[50, 1-12-2021]]
2nd | +100 | Repack | [[400, 1-12-2021], [50, 1-12-2021], | []
- | | | [50, 1-12-2021]] |

View File

@@ -3,7 +3,7 @@
import frappe
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data
from erpnext.tests.utils import ERPNextTestCase
@@ -11,15 +11,17 @@ class TestStockAgeing(ERPNextTestCase):
def setUp(self) -> None:
self.filters = frappe._dict(
company="_Test Company",
to_date="2021-12-10"
to_date="2021-12-10",
range1=30, range2=60, range3=90
)
def test_normal_inward_outward_queue(self):
"Reference: Case 1 in stock_ageing_fifo_logic.md"
"Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -27,6 +29,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=50,
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -34,6 +37,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -50,11 +54,12 @@ class TestStockAgeing(ERPNextTestCase):
self.assertEqual(queue[0][0], 20.0)
def test_insufficient_balance(self):
"Reference: Case 3 in stock_ageing_fifo_logic.md"
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=(-30), qty_after_transaction=(-30),
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -62,6 +67,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=(-10),
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -69,6 +75,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=10,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -76,6 +83,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=10, qty_after_transaction=20,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False, serial_no=None
@@ -91,11 +99,16 @@ class TestStockAgeing(ERPNextTestCase):
self.assertEqual(queue[0][0], 10.0)
self.assertEqual(queue[1][0], 10.0)
def test_stock_reconciliation(self):
def test_basic_stock_reconciliation(self):
"""
Ledger (same wh): [+30, reco reset >> 50, -10]
Bal: 40
"""
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -103,6 +116,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=50,
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -110,6 +124,7 @@ class TestStockAgeing(ERPNextTestCase):
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -122,5 +137,477 @@ class TestStockAgeing(ERPNextTestCase):
queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
self.assertEqual(result["total_qty"], 40.0)
self.assertEqual(queue[0][0], 20.0)
self.assertEqual(queue[1][0], 20.0)
def test_sequential_stock_reco_same_warehouse(self):
"""
Test back to back stock recos (same warehouse).
Ledger: [reco opening >> +1000, reco reset >> 400, -10]
Bal: 390
"""
sle = [
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=1000,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=400,
warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="003",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=390,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
)
]
slots = FIFOSlots(self.filters, sle).generate()
result = slots["Flask Item"]
queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
self.assertEqual(result["total_qty"], 390.0)
self.assertEqual(queue[0][0], 390.0)
def test_sequential_stock_reco_different_warehouse(self):
"""
Ledger:
WH | Voucher | Qty
-------------------
WH1 | Reco | 1000
WH2 | Reco | 400
WH1 | SE | -10
Bal: WH1 bal + WH2 bal = 990 + 400 = 1390
"""
sle = [
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=1000,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=400,
warehouse="WH 2",
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="003",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=990,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False, serial_no=None
)
]
item_wise_slots, item_wh_wise_slots = generate_item_and_item_wh_wise_slots(
filters=self.filters,sle=sle
)
# test without 'show_warehouse_wise_stock'
item_result = item_wise_slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
self.assertEqual(item_result["total_qty"], 1390.0)
self.assertEqual(queue[0][0], 990.0)
self.assertEqual(queue[1][0], 400.0)
# test with 'show_warehouse_wise_stock' checked
item_wh_balances = [item_wh_wise_slots.get(i).get("qty_after_transaction") for i in item_wh_wise_slots]
self.assertEqual(sum(item_wh_balances), item_result["qty_after_transaction"])
def test_repack_entry_same_item_split_rows(self):
"""
Split consumption rows and have single repacked item row (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 500 | 001
Item 1 | -50 | 002 (repack)
Item 1 | -50 | 002 (repack)
Item 1 | 100 | 002 (repack)
Case most likely for batch items. Test time bucket computation.
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=500, qty_after_transaction=500,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=450,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=400,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=100, qty_after_transaction=500,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
slots = FIFOSlots(self.filters, sle).generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], 500.0)
self.assertEqual(queue[0][0], 400.0)
self.assertEqual(queue[1][0], 50.0)
self.assertEqual(queue[2][0], 50.0)
# check if time buckets add up to balance qty
self.assertEqual(sum([i[0] for i in queue]), 500.0)
def test_repack_entry_same_item_overconsume(self):
"""
Over consume item and have less repacked item qty (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 500 | 001
Item 1 | -100 | 002 (repack)
Item 1 | 50 | 002 (repack)
Case most likely for batch items. Test time bucket computation.
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=500, qty_after_transaction=500,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-100), qty_after_transaction=400,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=50, qty_after_transaction=450,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
slots = FIFOSlots(self.filters, sle).generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], 450.0)
self.assertEqual(queue[0][0], 400.0)
self.assertEqual(queue[1][0], 50.0)
# check if time buckets add up to balance qty
self.assertEqual(sum([i[0] for i in queue]), 450.0)
def test_repack_entry_same_item_overconsume_with_split_rows(self):
"""
Over consume item and have less repacked item qty (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 20 | 001
Item 1 | -50 | 002 (repack)
Item 1 | -50 | 002 (repack)
Item 1 | 50 | 002 (repack)
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=20, qty_after_transaction=20,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-30),
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-80),
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=50, qty_after_transaction=(-30),
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], -30.0)
self.assertEqual(queue[0][0], -30.0)
# check transfer bucket
transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
self.assertEqual(transfer_bucket[0][0], 50)
def test_repack_entry_same_item_overproduce(self):
"""
Under consume item and have more repacked item qty (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 500 | 001
Item 1 | -50 | 002 (repack)
Item 1 | 100 | 002 (repack)
Case most likely for batch items. Test time bucket computation.
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=500, qty_after_transaction=500,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=450,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=100, qty_after_transaction=550,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
slots = FIFOSlots(self.filters, sle).generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], 550.0)
self.assertEqual(queue[0][0], 450.0)
self.assertEqual(queue[1][0], 50.0)
self.assertEqual(queue[2][0], 50.0)
# check if time buckets add up to balance qty
self.assertEqual(sum([i[0] for i in queue]), 550.0)
def test_repack_entry_same_item_overproduce_with_split_rows(self):
"""
Over consume item and have less repacked item qty (same warehouse).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | 20 | 001
Item 1 | -50 | 002 (repack)
Item 1 | 50 | 002 (repack)
Item 1 | 50 | 002 (repack)
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=20, qty_after_transaction=20,
warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-30),
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=50, qty_after_transaction=20,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=50, qty_after_transaction=70,
warehouse="WH 1",
posting_date="2021-12-04", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
]
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots["Flask Item"]
queue = item_result["fifo_queue"]
self.assertEqual(item_result["total_qty"], 70.0)
self.assertEqual(queue[0][0], 20.0)
self.assertEqual(queue[1][0], 50.0)
# check transfer bucket
transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
self.assertFalse(transfer_bucket)
def test_negative_stock_same_voucher(self):
"""
Test negative stock scenario in transfer bucket via repack entry (same wh).
Ledger:
Item | Qty | Voucher
------------------------
Item 1 | -50 | 001
Item 1 | -50 | 001
Item 1 | 30 | 001
Item 1 | 80 | 001
"""
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-50),
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict( # stock up item
name="Flask Item",
actual_qty=(-50), qty_after_transaction=(-100),
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict( # stock up item
name="Flask Item",
actual_qty=30, qty_after_transaction=(-70),
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
]
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots["Flask Item"]
# check transfer bucket
transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
self.assertEqual(transfer_bucket[0][0], 20)
self.assertEqual(transfer_bucket[1][0], 50)
self.assertEqual(item_result["fifo_queue"][0][0], -70.0)
sle.append(frappe._dict(
name="Flask Item",
actual_qty=80, qty_after_transaction=10,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
))
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots["Flask Item"]
transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
self.assertFalse(transfer_bucket)
self.assertEqual(item_result["fifo_queue"][0][0], 10.0)
def test_precision(self):
"Test if final balance qty is rounded off correctly."
sle = [
frappe._dict( # stock up item
name="Flask Item",
actual_qty=0.3, qty_after_transaction=0.3,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict( # stock up item
name="Flask Item",
actual_qty=0.6, qty_after_transaction=0.9,
warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
]
slots = FIFOSlots(self.filters, sle).generate()
report_data = format_report_data(self.filters, slots, self.filters["to_date"])
row = report_data[0] # first row in report
bal_qty = row[5]
range_qty_sum = sum([i for i in row[7:11]]) # get sum of range balance
# check if value of Available Qty column matches with range bucket post format
self.assertEqual(bal_qty, 0.9)
self.assertEqual(bal_qty, range_qty_sum)
def generate_item_and_item_wh_wise_slots(filters, sle):
"Return results with and without 'show_warehouse_wise_stock'"
item_wise_slots = FIFOSlots(filters, sle).generate()
filters.show_warehouse_wise_stock = True
item_wh_wise_slots = FIFOSlots(filters, sle).generate()
filters.show_warehouse_wise_stock = False
return item_wise_slots, item_wh_wise_slots

View File

@@ -86,10 +86,10 @@ frappe.query_reports["Stock Ledger"] = {
],
"formatter": function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (column.fieldname == "out_qty" && data.out_qty < 0) {
if (column.fieldname == "out_qty" && data && data.out_qty < 0) {
value = "<span style='color:red'>" + value + "</span>";
}
else if (column.fieldname == "in_qty" && data.in_qty > 0) {
else if (column.fieldname == "in_qty" && data && data.in_qty > 0) {
value = "<span style='color:green'>" + value + "</span>";
}

View File

@@ -104,6 +104,7 @@ def get_columns():
{"label": _("Incoming Rate"), "fieldname": "incoming_rate", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency", "convertible": "rate"},
{"label": _("Valuation Rate"), "fieldname": "valuation_rate", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency", "convertible": "rate"},
{"label": _("Balance Value"), "fieldname": "stock_value", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"},
{"label": _("Value Change"), "fieldname": "stock_value_difference", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"},
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110},
{"label": _("Voucher #"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 100},
{"label": _("Batch"), "fieldname": "batch_no", "fieldtype": "Link", "options": "Batch", "width": 100},

View File

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

View File

@@ -3,7 +3,7 @@
import frappe
from frappe.utils import cstr, flt, nowdate, nowtime
from frappe.utils import cstr, flt, now, nowdate, nowtime
from erpnext.controllers.stock_controller import create_repost_item_valuation_entry
from erpnext.stock.utils import update_bin
@@ -175,6 +175,7 @@ def update_bin_qty(item_code, warehouse, qty_dict=None):
bin.set(field, flt(value))
mismatch = True
bin.modified = now()
if mismatch:
bin.set_projected_qty()
bin.db_update()

View File

@@ -7,7 +7,7 @@ import json
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate
from frappe.utils import cint, cstr, flt, get_datetime, get_link_to_form, getdate, now, nowdate
from six import iteritems
import erpnext
@@ -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:
@@ -105,6 +114,7 @@ def get_args_for_future_sle(row):
def validate_serial_no(sle):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
for sn in get_serial_nos(sle.serial_no):
args = copy.deepcopy(sle)
args.serial_no = sn
@@ -415,6 +425,8 @@ class update_entries_after(object):
return sorted(entries_to_fix, key=lambda k: k['timestamp'])
def process_sle(self, sle):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
# previous sle data for this warehouse
self.wh_data = self.data[sle.warehouse]
@@ -429,7 +441,7 @@ class update_entries_after(object):
if not self.args.get("sle_id"):
self.get_dynamic_incoming_outgoing_rate(sle)
if sle.serial_no:
if get_serial_nos(sle.serial_no):
self.get_serialized_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
if sle.voucher_type == "Stock Reconciliation":
@@ -441,8 +453,9 @@ class update_entries_after(object):
# assert
self.wh_data.valuation_rate = sle.valuation_rate
self.wh_data.qty_after_transaction = sle.qty_after_transaction
self.wh_data.stock_queue = [[self.wh_data.qty_after_transaction, self.wh_data.valuation_rate]]
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
if self.valuation_method != "Moving Average":
self.wh_data.stock_queue = [[self.wh_data.qty_after_transaction, self.wh_data.valuation_rate]]
else:
if self.valuation_method == "Moving Average":
self.get_moving_average_values(sle)
@@ -455,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
@@ -595,9 +610,9 @@ class update_entries_after(object):
incoming_rate = self.wh_data.valuation_rate
stock_value_change = 0
if incoming_rate:
if actual_qty > 0:
stock_value_change = actual_qty * incoming_rate
elif actual_qty < 0:
else:
# In case of delivery/stock issue, get average purchase rate
# of serial nos of current entry
if not sle.is_cancelled:
@@ -618,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
@@ -639,6 +652,7 @@ class update_entries_after(object):
where
company = %s
and actual_qty > 0
and is_cancelled = 0
and (serial_no = %s
or serial_no like %s
or serial_no like %s
@@ -685,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)
@@ -718,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
@@ -783,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)
@@ -942,6 +959,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no,
item_code = %s
AND warehouse = %s
AND valuation_rate >= 0
AND is_cancelled = 0
AND NOT (voucher_no = %s AND voucher_type = %s)
order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type))
@@ -952,6 +970,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no,
where
item_code = %s
AND valuation_rate > 0
AND is_cancelled = 0
AND NOT(voucher_no = %s AND voucher_type = %s)
order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, voucher_no, voucher_type))
@@ -1132,26 +1151,31 @@ def get_future_sle_with_negative_qty(args):
def get_future_sle_with_negative_batch_qty(args):
return frappe.db.sql("""
with batch_ledger as (
select
posting_date, posting_time, voucher_type, voucher_no,
sum(actual_qty) over (order by posting_date, posting_time, creation) as cumulative_total
from `tabStock Ledger Entry`
where
item_code = %(item_code)s
and warehouse = %(warehouse)s
and batch_no=%(batch_no)s
and is_cancelled = 0
order by posting_date, posting_time, creation
)
select * from batch_ledger
batch_ledger = frappe.db.sql("""
select
posting_date, posting_time, voucher_type, voucher_no, actual_qty
from `tabStock Ledger Entry`
where
cumulative_total < 0.0
and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s)
limit 1
item_code = %(item_code)s
and warehouse = %(warehouse)s
and batch_no=%(batch_no)s
and is_cancelled = 0
order by timestamp(posting_date, posting_time), creation
""", args, as_dict=1)
cumulative_total = 0.0
current_posting_datetime = get_datetime(str(args.posting_date) + " " + str(args.posting_time))
for entry in batch_ledger:
cumulative_total += entry.actual_qty
if cumulative_total > -1e-6:
continue
if (get_datetime(str(entry.posting_date) + " " + str(entry.posting_time))
>= current_posting_datetime):
entry.cumulative_total = cumulative_total
return [entry]
def _round_off_if_near_zero(number: float, precision: int = 6) -> float:
""" Rounds off the number to zero only if number is close to zero for decimal

View File

@@ -104,7 +104,7 @@ def get_stock_balance(item_code, warehouse, posting_date=None, posting_time=None
serial_nos = get_serial_nos_data_after_transactions(args)
return ((last_entry.qty_after_transaction, last_entry.valuation_rate, serial_nos)
if last_entry else (0.0, 0.0, 0.0))
if last_entry else (0.0, 0.0, None))
else:
return (last_entry.qty_after_transaction, last_entry.valuation_rate) if last_entry else (0.0, 0.0)
else:
@@ -177,13 +177,7 @@ def get_latest_stock_balance():
def get_bin(item_code, warehouse):
bin = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse})
if not bin:
bin_obj = frappe.get_doc({
"doctype": "Bin",
"item_code": item_code,
"warehouse": warehouse,
})
bin_obj.flags.ignore_permissions = 1
bin_obj.insert()
bin_obj = _create_bin(item_code, warehouse)
else:
bin_obj = frappe.get_doc('Bin', bin, for_update=True)
bin_obj.flags.ignore_permissions = True
@@ -193,16 +187,24 @@ def get_or_make_bin(item_code: str , warehouse: str) -> str:
bin_record = frappe.db.get_value('Bin', {'item_code': item_code, 'warehouse': warehouse})
if not bin_record:
bin_obj = frappe.get_doc({
"doctype": "Bin",
"item_code": item_code,
"warehouse": warehouse,
})
bin_obj = _create_bin(item_code, warehouse)
bin_record = bin_obj.name
return bin_record
def _create_bin(item_code, warehouse):
"""Create a bin and take care of concurrent inserts."""
bin_creation_savepoint = "create_bin"
try:
frappe.db.savepoint(bin_creation_savepoint)
bin_obj = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse)
bin_obj.flags.ignore_permissions = 1
bin_obj.insert()
bin_record = bin_obj.name
except frappe.UniqueValidationError:
frappe.db.rollback(save_point=bin_creation_savepoint) # preserve transaction in postgres
bin_obj = frappe.get_last_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
return bin_record
return bin_obj
def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False):
"""WARNING: This function is deprecated. Inline this function instead of using it."""
@@ -420,6 +422,19 @@ def is_reposting_item_valuation_in_progress():
if reposting_in_progress:
frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1)
def calculate_mapped_packed_items_return(return_doc):
parent_items = set([item.parent_item for item in return_doc.packed_items])
against_doc = frappe.get_doc(return_doc.doctype, return_doc.return_against)
for original_bundle, returned_bundle in zip(against_doc.items, return_doc.items):
if original_bundle.item_code in parent_items:
for returned_packed_item, original_packed_item in zip(return_doc.packed_items, against_doc.packed_items):
if returned_packed_item.parent_item == original_bundle.item_code:
returned_packed_item.parent_detail_docname = returned_bundle.name
returned_packed_item.qty = (original_packed_item.qty / original_bundle.qty) * returned_bundle.qty
def check_pending_reposting(posting_date: str, throw_error: bool = True) -> bool:
"""Check if there are pending reposting job till the specified posting date."""