mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-25 07:54:46 +00:00
Merge branch 'version-13' of https://github.com/frappe/erpnext into enterprise-hotfix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
158
erpnext/stock/doctype/packed_item/test_packed_item.py
Normal file
158
erpnext/stock/doctype/packed_item/test_packed_item.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -9,8 +9,7 @@ from collections import defaultdict
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, floor, flt, nowdate
|
||||
from six import string_types
|
||||
from frappe.utils import cint, cstr, floor, flt, nowdate
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.utils import get_stock_balance
|
||||
@@ -75,7 +74,7 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
|
||||
purpose: Purpose of Stock Entry
|
||||
sync (optional): Sync with client side only for client side calls
|
||||
"""
|
||||
if isinstance(items, string_types):
|
||||
if isinstance(items, str):
|
||||
items = json.loads(items)
|
||||
|
||||
items_not_accomodated, updated_table = [], []
|
||||
@@ -143,11 +142,44 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
|
||||
if items_not_accomodated:
|
||||
show_unassigned_items_message(items_not_accomodated)
|
||||
|
||||
items[:] = updated_table if updated_table else items # modify items table
|
||||
if updated_table and _items_changed(items, updated_table, doctype):
|
||||
items[:] = updated_table
|
||||
frappe.msgprint(_("Applied putaway rules."), alert=True)
|
||||
|
||||
if sync and json.loads(sync): # sync with client side
|
||||
return items
|
||||
|
||||
def _items_changed(old, new, doctype: str) -> bool:
|
||||
""" Check if any items changed by application of putaway rules.
|
||||
|
||||
If not, changing item table can have side effects since `name` items also changes.
|
||||
"""
|
||||
if len(old) != len(new):
|
||||
return True
|
||||
|
||||
old = [frappe._dict(item) if isinstance(item, dict) else item for item in old]
|
||||
|
||||
if doctype == "Stock Entry":
|
||||
compare_keys = ("item_code", "t_warehouse", "transfer_qty", "serial_no")
|
||||
sort_key = lambda item: (item.item_code, cstr(item.t_warehouse), # noqa
|
||||
flt(item.transfer_qty), cstr(item.serial_no))
|
||||
else:
|
||||
# purchase receipt / invoice
|
||||
compare_keys = ("item_code", "warehouse", "stock_qty", "received_qty", "serial_no")
|
||||
sort_key = lambda item: (item.item_code, cstr(item.warehouse), # noqa
|
||||
flt(item.stock_qty), flt(item.received_qty), cstr(item.serial_no))
|
||||
|
||||
old_sorted = sorted(old, key=sort_key)
|
||||
new_sorted = sorted(new, key=sort_key)
|
||||
|
||||
# Once sorted by all relevant keys both tables should align if they are same.
|
||||
for old_item, new_item in zip(old_sorted, new_sorted):
|
||||
for key in compare_keys:
|
||||
if old_item.get(key) != new_item.get(key):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_ordered_putaway_rules(item_code, company, source_warehouse=None):
|
||||
"""Returns an ordered list of putaway rules to apply on an item."""
|
||||
filters = {
|
||||
|
||||
@@ -35,6 +35,18 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
new_uom.uom_name = "Bag"
|
||||
new_uom.save()
|
||||
|
||||
def assertUnchangedItemsOnResave(self, doc):
|
||||
""" Check if same items remain even after reapplication of rules.
|
||||
|
||||
This is required since some business logic like subcontracting
|
||||
depends on `name` of items to be same if item isn't changed.
|
||||
"""
|
||||
doc.reload()
|
||||
old_items = {d.name for d in doc.items}
|
||||
doc.save()
|
||||
new_items = {d.name for d in doc.items}
|
||||
self.assertSetEqual(old_items, new_items)
|
||||
|
||||
def test_putaway_rules_priority(self):
|
||||
"""Test if rule is applied by priority, irrespective of free space."""
|
||||
rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200,
|
||||
@@ -50,6 +62,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(pr.items[1].qty, 100)
|
||||
self.assertEqual(pr.items[1].warehouse, self.warehouse_2)
|
||||
|
||||
self.assertUnchangedItemsOnResave(pr)
|
||||
|
||||
pr.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -162,6 +176,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
# leftover space was for 500 kg (0.5 Bag)
|
||||
# Since Bag is a whole UOM, 1(out of 2) Bag will be unassigned
|
||||
|
||||
self.assertUnchangedItemsOnResave(pr)
|
||||
|
||||
pr.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -196,6 +212,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(pr.items[1].warehouse, self.warehouse_1)
|
||||
self.assertEqual(pr.items[1].putaway_rule, rule_1.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(pr)
|
||||
|
||||
pr.delete()
|
||||
rule_1.delete()
|
||||
|
||||
@@ -239,6 +257,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry_item.qty, 100) # unassigned 100 out of 200 Kg
|
||||
self.assertEqual(stock_entry_item.putaway_rule, rule_2.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -294,6 +314,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry.items[2].qty, 200)
|
||||
self.assertEqual(stock_entry.items[2].putaway_rule, rule_2.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
@@ -344,6 +366,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry.items[1].serial_no, "\n".join(serial_nos[3:]))
|
||||
self.assertEqual(stock_entry.items[1].batch_no, "BOTTL-BATCH-1")
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
pr.cancel()
|
||||
rule_1.delete()
|
||||
@@ -366,6 +390,8 @@ class TestPutawayRule(ERPNextTestCase):
|
||||
self.assertEqual(stock_entry_item.qty, 100)
|
||||
self.assertEqual(stock_entry_item.putaway_rule, rule_1.name)
|
||||
|
||||
self.assertUnchangedItemsOnResave(stock_entry)
|
||||
|
||||
stock_entry.delete()
|
||||
rule_1.delete()
|
||||
rule_2.delete()
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "hash",
|
||||
"creation": "2013-03-29 18:22:12",
|
||||
"creation": "2022-02-05 00:17:49.860824",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Other",
|
||||
"editable_grid": 1,
|
||||
@@ -340,13 +340,13 @@
|
||||
"label": "More Information"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "0",
|
||||
"fieldname": "allow_zero_valuation_rate",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Zero Valuation Rate",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1
|
||||
"print_hide": 1,
|
||||
"read_only_depends_on": "eval:doc.s_warehouse"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
@@ -556,12 +556,14 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-06-22 16:47:11.268975",
|
||||
"modified": "2022-02-26 00:51:24.963653",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry Detail",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "ASC"
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
@@ -1,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)
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]] |
|
||||
|
||||
@@ -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
|
||||
@@ -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>";
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user