Merge branch 'develop' of https://github.com/frappe/erpnext into loan_bank_reco

This commit is contained in:
Deepesh Garg 2022-02-21 22:22:02 +05:30
commit 0d8c24f488
25 changed files with 862 additions and 133 deletions

View File

@ -8,20 +8,22 @@ frappe.query_reports["Gross Profit"] = {
"label": __("Company"), "label": __("Company"),
"fieldtype": "Link", "fieldtype": "Link",
"options": "Company", "options": "Company",
"reqd": 1, "default": frappe.defaults.get_user_default("Company"),
"default": frappe.defaults.get_user_default("Company") "reqd": 1
}, },
{ {
"fieldname":"from_date", "fieldname":"from_date",
"label": __("From Date"), "label": __("From Date"),
"fieldtype": "Date", "fieldtype": "Date",
"default": frappe.defaults.get_user_default("year_start_date") "default": frappe.defaults.get_user_default("year_start_date"),
"reqd": 1
}, },
{ {
"fieldname":"to_date", "fieldname":"to_date",
"label": __("To Date"), "label": __("To Date"),
"fieldtype": "Date", "fieldtype": "Date",
"default": frappe.defaults.get_user_default("year_end_date") "default": frappe.defaults.get_user_default("year_end_date"),
"reqd": 1
}, },
{ {
"fieldname":"sales_invoice", "fieldname":"sales_invoice",

View File

@ -1,5 +1,5 @@
{ {
"add_total_row": 0, "add_total_row": 1,
"columns": [], "columns": [],
"creation": "2013-02-25 17:03:34", "creation": "2013-02-25 17:03:34",
"disable_prepared_report": 0, "disable_prepared_report": 0,
@ -9,7 +9,7 @@
"filters": [], "filters": [],
"idx": 3, "idx": 3,
"is_standard": "Yes", "is_standard": "Yes",
"modified": "2021-11-13 19:14:23.730198", "modified": "2022-02-11 10:18:36.956558",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Gross Profit", "name": "Gross Profit",

View File

@ -70,43 +70,42 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
data.append(row) data.append(row)
def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_columns, data): def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_columns, data):
for idx, src in enumerate(gross_profit_data.grouped_data): for src in gross_profit_data.grouped_data:
row = [] row = []
for col in group_wise_columns.get(scrub(filters.group_by)): for col in group_wise_columns.get(scrub(filters.group_by)):
row.append(src.get(col)) row.append(src.get(col))
row.append(filters.currency) row.append(filters.currency)
if idx == len(gross_profit_data.grouped_data)-1:
row[0] = "Total"
data.append(row) data.append(row)
def get_columns(group_wise_columns, filters): def get_columns(group_wise_columns, filters):
columns = [] columns = []
column_map = frappe._dict({ column_map = frappe._dict({
"parent": _("Sales Invoice") + ":Link/Sales Invoice:120", "parent": {"label": _('Sales Invoice'), "fieldname": "parent_invoice", "fieldtype": "Link", "options": "Sales Invoice", "width": 120},
"invoice_or_item": _("Sales Invoice") + ":Link/Sales Invoice:120", "invoice_or_item": {"label": _('Sales Invoice'), "fieldtype": "Link", "options": "Sales Invoice", "width": 120},
"posting_date": _("Posting Date") + ":Date:100", "posting_date": {"label": _('Posting Date'), "fieldname": "posting_date", "fieldtype": "Date", "width": 100},
"posting_time": _("Posting Time") + ":Data:100", "posting_time": {"label": _('Posting Time'), "fieldname": "posting_time", "fieldtype": "Data", "width": 100},
"item_code": _("Item Code") + ":Link/Item:100", "item_code": {"label": _('Item Code'), "fieldname": "item_code", "fieldtype": "Link", "options": "Item", "width": 100},
"item_name": _("Item Name") + ":Data:100", "item_name": {"label": _('Item Name'), "fieldname": "item_name", "fieldtype": "Data", "width": 100},
"item_group": _("Item Group") + ":Link/Item Group:100", "item_group": {"label": _('Item Group'), "fieldname": "item_group", "fieldtype": "Link", "options": "Item Group", "width": 100},
"brand": _("Brand") + ":Link/Brand:100", "brand": {"label": _('Brand'), "fieldtype": "Link", "options": "Brand", "width": 100},
"description": _("Description") +":Data:100", "description": {"label": _('Description'), "fieldname": "description", "fieldtype": "Data", "width": 100},
"warehouse": _("Warehouse") + ":Link/Warehouse:100", "warehouse": {"label": _('Warehouse'), "fieldname": "warehouse", "fieldtype": "Link", "options": "warehouse", "width": 100},
"qty": _("Qty") + ":Float:80", "qty": {"label": _('Qty'), "fieldname": "qty", "fieldtype": "Float", "width": 80},
"base_rate": _("Avg. Selling Rate") + ":Currency/currency:100", "base_rate": {"label": _('Avg. Selling Rate'), "fieldname": "avg._selling_rate", "fieldtype": "Currency", "options": "currency", "width": 100},
"buying_rate": _("Valuation Rate") + ":Currency/currency:100", "buying_rate": {"label": _('Valuation Rate'), "fieldname": "valuation_rate", "fieldtype": "Currency", "options": "currency", "width": 100},
"base_amount": _("Selling Amount") + ":Currency/currency:100", "base_amount": {"label": _('Selling Amount'), "fieldname": "selling_amount", "fieldtype": "Currency", "options": "currency", "width": 100},
"buying_amount": _("Buying Amount") + ":Currency/currency:100", "buying_amount": {"label": _('Buying Amount'), "fieldname": "buying_amount", "fieldtype": "Currency", "options": "currency", "width": 100},
"gross_profit": _("Gross Profit") + ":Currency/currency:100", "gross_profit": {"label": _('Gross Profit'), "fieldname": "gross_profit", "fieldtype": "Currency", "options": "currency", "width": 100},
"gross_profit_percent": _("Gross Profit %") + ":Percent:100", "gross_profit_percent": {"label": _('Gross Profit Percent'), "fieldname": "gross_profit_%",
"project": _("Project") + ":Link/Project:100", "fieldtype": "Percent", "width": 100},
"sales_person": _("Sales person"), "project": {"label": _('Project'), "fieldname": "project", "fieldtype": "Link", "options": "Project", "width": 100},
"allocated_amount": _("Allocated Amount") + ":Currency/currency:100", "sales_person": {"label": _('Sales Person'), "fieldname": "sales_person", "fieldtype": "Data","width": 100},
"customer": _("Customer") + ":Link/Customer:100", "allocated_amount": {"label": _('Allocated Amount'), "fieldname": "allocated_amount", "fieldtype": "Currency", "options": "currency", "width": 100},
"customer_group": _("Customer Group") + ":Link/Customer Group:100", "customer": {"label": _('Customer'), "fieldname": "customer", "fieldtype": "Link", "options": "Customer", "width": 100},
"territory": _("Territory") + ":Link/Territory:100" "customer_group": {"label": _('Customer Group'), "fieldname": "customer_group", "fieldtype": "Link", "options": "customer", "width": 100},
"territory": {"label": _('Territory'), "fieldname": "territory", "fieldtype": "Link", "options": "territory", "width": 100},
}) })
for col in group_wise_columns.get(scrub(filters.group_by)): for col in group_wise_columns.get(scrub(filters.group_by)):
@ -173,7 +172,7 @@ class GrossProfitGenerator(object):
buying_amount = 0 buying_amount = 0
for row in reversed(self.si_list): for row in reversed(self.si_list):
if self.skip_row(row, self.product_bundles): if self.skip_row(row):
continue continue
row.base_amount = flt(row.base_net_amount, self.currency_precision) row.base_amount = flt(row.base_net_amount, self.currency_precision)
@ -223,16 +222,6 @@ class GrossProfitGenerator(object):
self.get_average_rate_based_on_group_by() self.get_average_rate_based_on_group_by()
def get_average_rate_based_on_group_by(self): def get_average_rate_based_on_group_by(self):
# sum buying / selling totals for group
self.totals = frappe._dict(
qty=0,
base_amount=0,
buying_amount=0,
gross_profit=0,
gross_profit_percent=0,
base_rate=0,
buying_rate=0
)
for key in list(self.grouped): for key in list(self.grouped):
if self.filters.get("group_by") != "Invoice": if self.filters.get("group_by") != "Invoice":
for i, row in enumerate(self.grouped[key]): for i, row in enumerate(self.grouped[key]):
@ -244,7 +233,6 @@ class GrossProfitGenerator(object):
new_row.base_amount += flt(row.base_amount, self.currency_precision) new_row.base_amount += flt(row.base_amount, self.currency_precision)
new_row = self.set_average_rate(new_row) new_row = self.set_average_rate(new_row)
self.grouped_data.append(new_row) self.grouped_data.append(new_row)
self.add_to_totals(new_row)
else: else:
for i, row in enumerate(self.grouped[key]): for i, row in enumerate(self.grouped[key]):
if row.indent == 1.0: if row.indent == 1.0:
@ -258,17 +246,6 @@ class GrossProfitGenerator(object):
if (flt(row.qty) or row.base_amount): if (flt(row.qty) or row.base_amount):
row = self.set_average_rate(row) row = self.set_average_rate(row)
self.grouped_data.append(row) self.grouped_data.append(row)
self.add_to_totals(row)
self.set_average_gross_profit(self.totals)
if self.filters.get("group_by") == "Invoice":
self.totals.indent = 0.0
self.totals.parent_invoice = ""
self.totals.invoice_or_item = "Total"
self.si_list.append(self.totals)
else:
self.grouped_data.append(self.totals)
def is_not_invoice_row(self, row): def is_not_invoice_row(self, row):
return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get("group_by") != "Invoice" return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get("group_by") != "Invoice"
@ -284,11 +261,6 @@ class GrossProfitGenerator(object):
new_row.gross_profit_percent = flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision) \ new_row.gross_profit_percent = flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision) \
if new_row.base_amount else 0 if new_row.base_amount else 0
def add_to_totals(self, new_row):
for key in self.totals:
if new_row.get(key):
self.totals[key] += new_row[key]
def get_returned_invoice_items(self): def get_returned_invoice_items(self):
returned_invoices = frappe.db.sql(""" returned_invoices = frappe.db.sql("""
select select
@ -306,12 +278,12 @@ class GrossProfitGenerator(object):
self.returned_invoices.setdefault(inv.return_against, frappe._dict())\ self.returned_invoices.setdefault(inv.return_against, frappe._dict())\
.setdefault(inv.item_code, []).append(inv) .setdefault(inv.item_code, []).append(inv)
def skip_row(self, row, product_bundles): def skip_row(self, row):
if self.filters.get("group_by") != "Invoice": if self.filters.get("group_by") != "Invoice":
if not row.get(scrub(self.filters.get("group_by", ""))): if not row.get(scrub(self.filters.get("group_by", ""))):
return True return True
elif row.get("is_return") == 1:
return True return False
def get_buying_amount_from_product_bundle(self, row, product_bundle): def get_buying_amount_from_product_bundle(self, row, product_bundle):
buying_amount = 0.0 buying_amount = 0.0
@ -369,20 +341,37 @@ class GrossProfitGenerator(object):
return self.average_buying_rate[item_code] return self.average_buying_rate[item_code]
def get_last_purchase_rate(self, item_code, row): def get_last_purchase_rate(self, item_code, row):
condition = '' purchase_invoice = frappe.qb.DocType("Purchase Invoice")
if row.project: purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
condition += " AND a.project=%s" % (frappe.db.escape(row.project))
elif row.cost_center:
condition += " AND a.cost_center=%s" % (frappe.db.escape(row.cost_center))
if self.filters.to_date:
condition += " AND modified='%s'" % (self.filters.to_date)
last_purchase_rate = frappe.db.sql(""" query = (frappe.qb.from_(purchase_invoice_item)
select (a.base_rate / a.conversion_factor) .inner_join(
from `tabPurchase Invoice Item` a purchase_invoice
where a.item_code = %s and a.docstatus=1 ).on(
{0} purchase_invoice.name == purchase_invoice_item.parent
order by a.modified desc limit 1""".format(condition), item_code) ).select(
purchase_invoice_item.base_rate / purchase_invoice_item.conversion_factor
).where(
purchase_invoice.docstatus == 1
).where(
purchase_invoice.posting_date <= self.filters.to_date
).where(
purchase_invoice_item.item_code == item_code
))
if row.project:
query.where(
purchase_invoice_item.project == row.project
)
if row.cost_center:
query.where(
purchase_invoice_item.cost_center == row.cost_center
)
query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc)
query.limit(1)
last_purchase_rate = query.run()
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0 return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0

View File

@ -249,6 +249,7 @@ class BuyingController(StockController, Subcontracting):
"posting_time": self.get('posting_time'), "posting_time": self.get('posting_time'),
"qty": -1 * flt(d.get('stock_qty')), "qty": -1 * flt(d.get('stock_qty')),
"serial_no": d.get('serial_no'), "serial_no": d.get('serial_no'),
"batch_no": d.get("batch_no"),
"company": self.company, "company": self.company,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,
@ -278,7 +279,8 @@ class BuyingController(StockController, Subcontracting):
"posting_date": self.posting_date, "posting_date": self.posting_date,
"posting_time": self.posting_time, "posting_time": self.posting_time,
"qty": -1 * d.consumed_qty, "qty": -1 * d.consumed_qty,
"serial_no": d.serial_no "serial_no": d.serial_no,
"batch_no": d.batch_no,
}) })
if rate > 0: if rate > 0:

View File

@ -420,6 +420,7 @@ def get_rate_for_return(voucher_type, voucher_no, item_code, return_against=None
"posting_time": sle.get('posting_time'), "posting_time": sle.get('posting_time'),
"qty": sle.actual_qty, "qty": sle.actual_qty,
"serial_no": sle.get('serial_no'), "serial_no": sle.get('serial_no'),
"batch_no": sle.get("batch_no"),
"company": sle.company, "company": sle.company,
"voucher_type": sle.voucher_type, "voucher_type": sle.voucher_type,
"voucher_no": sle.voucher_no "voucher_no": sle.voucher_no

View File

@ -394,6 +394,7 @@ class SellingController(StockController):
"posting_time": self.get('posting_time') or nowtime(), "posting_time": self.get('posting_time') or nowtime(),
"qty": qty if cint(self.get("is_return")) else (-1 * qty), "qty": qty if cint(self.get("is_return")) else (-1 * qty),
"serial_no": d.get('serial_no'), "serial_no": d.get('serial_no'),
"batch_no": d.get("batch_no"),
"company": self.company, "company": self.company,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,

View File

@ -62,7 +62,7 @@ class JobCard(Document):
if self.get('time_logs'): if self.get('time_logs'):
for d in self.get('time_logs'): for d in self.get('time_logs'):
if get_datetime(d.from_time) > get_datetime(d.to_time): if d.to_time and get_datetime(d.from_time) > get_datetime(d.to_time):
frappe.throw(_("Row {0}: From time must be less than to time").format(d.idx)) frappe.throw(_("Row {0}: From time must be less than to time").format(d.idx))
data = self.get_overlap_for(d) data = self.get_overlap_for(d)

View File

@ -354,3 +354,4 @@ erpnext.patches.v13_0.update_exchange_rate_settings
erpnext.patches.v14_0.delete_amazon_mws_doctype erpnext.patches.v14_0.delete_amazon_mws_doctype
erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr
erpnext.patches.v13_0.update_accounts_in_loan_docs erpnext.patches.v13_0.update_accounts_in_loan_docs
erpnext.patches.v14_0.update_batch_valuation_flag

View File

@ -0,0 +1,11 @@
import frappe
def execute():
"""
- Don't use batchwise valuation for existing batches.
- Only batches created after this patch shoule use it.
"""
batch = frappe.qb.DocType("Batch")
frappe.qb.update(batch).set(batch.use_batchwise_valuation, 0).run()

View File

@ -719,6 +719,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
'posting_time': posting_time, 'posting_time': posting_time,
'qty': item.qty * item.conversion_factor, 'qty': item.qty * item.conversion_factor,
'serial_no': item.serial_no, 'serial_no': item.serial_no,
'batch_no': item.batch_no,
'voucher_type': voucher_type, 'voucher_type': voucher_type,
'company': company, 'company': company,
'allow_zero_valuation_rate': item.allow_zero_valuation_rate 'allow_zero_valuation_rate': item.allow_zero_valuation_rate

View File

@ -9,6 +9,8 @@
"field_order": [ "field_order": [
"sb_disabled", "sb_disabled",
"disabled", "disabled",
"column_break_24",
"use_batchwise_valuation",
"sb_batch", "sb_batch",
"batch_id", "batch_id",
"item", "item",
@ -186,6 +188,18 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Produced Qty", "label": "Produced Qty",
"read_only": 1 "read_only": 1
},
{
"fieldname": "column_break_24",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "use_batchwise_valuation",
"fieldtype": "Check",
"label": "Use Batch-wise Valuation",
"read_only": 1,
"set_only_once": 1
} }
], ],
"icon": "fa fa-archive", "icon": "fa fa-archive",
@ -193,10 +207,11 @@
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"max_attachments": 5, "max_attachments": 5,
"modified": "2021-07-08 16:22:01.343105", "modified": "2022-02-21 08:08:23.999236",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Batch", "name": "Batch",
"naming_rule": "By fieldname",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@ -217,6 +232,7 @@
"quick_entry": 1, "quick_entry": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"title_field": "batch_id", "title_field": "batch_id",
"track_changes": 1 "track_changes": 1
} }

View File

@ -110,11 +110,18 @@ class Batch(Document):
def validate(self): def validate(self):
self.item_has_batch_enabled() self.item_has_batch_enabled()
self.set_batchwise_valuation()
def item_has_batch_enabled(self): def item_has_batch_enabled(self):
if frappe.db.get_value("Item", self.item, "has_batch_no") == 0: if frappe.db.get_value("Item", self.item, "has_batch_no") == 0:
frappe.throw(_("The selected item cannot have Batch")) frappe.throw(_("The selected item cannot have Batch"))
def set_batchwise_valuation(self):
from erpnext.stock.stock_ledger import get_valuation_method
if self.is_new() and get_valuation_method(self.item) != "Moving Average":
self.use_batchwise_valuation = 1
def before_save(self): def before_save(self):
has_expiry_date, shelf_life_in_days = frappe.db.get_value('Item', self.item, ['has_expiry_date', 'shelf_life_in_days']) has_expiry_date, shelf_life_in_days = frappe.db.get_value('Item', self.item, ['has_expiry_date', 'shelf_life_in_days'])
if not self.expiry_date and has_expiry_date and shelf_life_in_days: if not self.expiry_date and has_expiry_date and shelf_life_in_days:

View File

@ -1,13 +1,21 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import json
import frappe import frappe
from frappe.exceptions import ValidationError from frappe.exceptions import ValidationError
from frappe.utils import cint, flt from frappe.utils import cint, flt
from frappe.utils.data import add_to_date, getdate
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
from erpnext.stock.get_item_details import get_item_details from erpnext.stock.get_item_details import get_item_details
from erpnext.stock.stock_ledger import get_valuation_rate
from erpnext.tests.utils import ERPNextTestCase from erpnext.tests.utils import ERPNextTestCase
@ -300,6 +308,105 @@ class TestBatch(ERPNextTestCase):
details = get_item_details(args) details = get_item_details(args)
self.assertEqual(details.get('price_list_rate'), 400) self.assertEqual(details.get('price_list_rate'), 400)
def test_basic_batch_wise_valuation(self, batch_qty = 100):
item_code = "_TestBatchWiseVal"
warehouse = "_Test Warehouse - _TC"
self.make_batch_item(item_code)
rates = [42, 420]
batches = {}
for rate in rates:
se = make_stock_entry(item_code=item_code, qty=10, rate=rate, target=warehouse)
batches[se.items[0].batch_no] = rate
LOW, HIGH = list(batches.keys())
# consume things out of order
consumption_plan = [
(HIGH, 1),
(LOW, 2),
(HIGH, 2),
(HIGH, 4),
(LOW, 6),
]
stock_value = sum(rates) * 10
qty_after_transaction = 20
for batch, qty in consumption_plan:
# consume out of order
se = make_stock_entry(item_code=item_code, source=warehouse, qty=qty, batch_no=batch)
sle = frappe.get_last_doc("Stock Ledger Entry", {"is_cancelled": 0, "voucher_no": se.name})
stock_value_difference = sle.actual_qty * batches[sle.batch_no]
self.assertAlmostEqual(sle.stock_value_difference, stock_value_difference)
stock_value += stock_value_difference
self.assertAlmostEqual(sle.stock_value, stock_value)
qty_after_transaction += sle.actual_qty
self.assertAlmostEqual(sle.qty_after_transaction, qty_after_transaction)
self.assertAlmostEqual(sle.valuation_rate, stock_value / qty_after_transaction)
self.assertEqual(json.loads(sle.stock_queue), []) # queues don't apply on batched items
def test_moving_batch_valuation_rates(self):
item_code = "_TestBatchWiseVal"
warehouse = "_Test Warehouse - _TC"
self.make_batch_item(item_code)
def assertValuation(expected):
actual = get_valuation_rate(item_code, warehouse, "voucher_type", "voucher_no", batch_no=batch_no)
self.assertAlmostEqual(actual, expected)
se = make_stock_entry(item_code=item_code, qty=100, rate=10, target=warehouse)
batch_no = se.items[0].batch_no
assertValuation(10)
# consumption should never affect current valuation rate
make_stock_entry(item_code=item_code, qty=20, source=warehouse)
assertValuation(10)
make_stock_entry(item_code=item_code, qty=30, source=warehouse)
assertValuation(10)
# 50 * 10 = 500 current value, add more item with higher valuation
make_stock_entry(item_code=item_code, qty=50, rate=20, target=warehouse, batch_no=batch_no)
assertValuation(15)
# consuming again shouldn't do anything
make_stock_entry(item_code=item_code, qty=20, source=warehouse)
assertValuation(15)
# reset rate with stock reconiliation
create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=10, rate=25, batch_no=batch_no)
assertValuation(25)
make_stock_entry(item_code=item_code, qty=20, rate=20, target=warehouse, batch_no=batch_no)
assertValuation((20 * 20 + 10 * 25) / (10 + 20))
def test_update_batch_properties(self):
item_code = "_TestBatchWiseVal"
self.make_batch_item(item_code)
se = make_stock_entry(item_code=item_code, qty=100, rate=10, target="_Test Warehouse - _TC")
batch_no = se.items[0].batch_no
batch = frappe.get_doc("Batch", batch_no)
expiry_date = add_to_date(batch.manufacturing_date, days=30)
batch.expiry_date = expiry_date
batch.save()
batch.reload()
self.assertEqual(getdate(batch.expiry_date), getdate(expiry_date))
def create_batch(item_code, rate, create_item_price_for_batch): def create_batch(item_code, rate, create_item_price_for_batch):
pi = make_purchase_invoice(company="_Test Company", pi = make_purchase_invoice(company="_Test Company",
warehouse= "Stores - _TC", cost_center = "Main - _TC", update_stock=1, warehouse= "Stores - _TC", cost_center = "Main - _TC", update_stock=1,

View File

@ -594,7 +594,7 @@ $.extend(erpnext.item, {
const increment = r.message.increment; const increment = r.message.increment;
let values = []; let values = [];
for(var i = from; i <= to; i += increment) { for(var i = from; i <= to; i = flt(i + increment, 6)) {
values.push(i); values.push(i);
} }
attr_val_fields[d.attribute] = values; attr_val_fields[d.attribute] = values;

View File

@ -1540,6 +1540,7 @@ def make_purchase_receipt(**args):
"conversion_factor": args.conversion_factor or 1.0, "conversion_factor": args.conversion_factor or 1.0,
"stock_qty": flt(qty) * (flt(args.conversion_factor) or 1.0), "stock_qty": flt(qty) * (flt(args.conversion_factor) or 1.0),
"serial_no": args.serial_no, "serial_no": args.serial_no,
"batch_no": args.batch_no,
"stock_uom": args.stock_uom or "_Test UOM", "stock_uom": args.stock_uom or "_Test UOM",
"uom": uom, "uom": uom,
"cost_center": args.cost_center or frappe.get_cached_value('Company', pr.company, 'cost_center'), "cost_center": args.cost_center or frappe.get_cached_value('Company', pr.company, 'cost_center'),

View File

@ -425,6 +425,7 @@ frappe.ui.form.on('Stock Entry', {
'posting_time' : frm.doc.posting_time, 'posting_time' : frm.doc.posting_time,
'warehouse' : cstr(item.s_warehouse) || cstr(item.t_warehouse), 'warehouse' : cstr(item.s_warehouse) || cstr(item.t_warehouse),
'serial_no' : item.serial_no, 'serial_no' : item.serial_no,
'batch_no' : item.batch_no,
'company' : frm.doc.company, 'company' : frm.doc.company,
'qty' : item.s_warehouse ? -1*flt(item.transfer_qty) : flt(item.transfer_qty), 'qty' : item.s_warehouse ? -1*flt(item.transfer_qty) : flt(item.transfer_qty),
'voucher_type' : frm.doc.doctype, 'voucher_type' : frm.doc.doctype,
@ -457,6 +458,7 @@ frappe.ui.form.on('Stock Entry', {
'warehouse': cstr(child.s_warehouse) || cstr(child.t_warehouse), 'warehouse': cstr(child.s_warehouse) || cstr(child.t_warehouse),
'transfer_qty': child.transfer_qty, 'transfer_qty': child.transfer_qty,
'serial_no': child.serial_no, 'serial_no': child.serial_no,
'batch_no': child.batch_no,
'qty': child.s_warehouse ? -1* child.transfer_qty : child.transfer_qty, 'qty': child.s_warehouse ? -1* child.transfer_qty : child.transfer_qty,
'posting_date': frm.doc.posting_date, 'posting_date': frm.doc.posting_date,
'posting_time': frm.doc.posting_time, 'posting_time': frm.doc.posting_time,
@ -680,6 +682,7 @@ frappe.ui.form.on('Stock Entry Detail', {
'warehouse' : cstr(d.s_warehouse) || cstr(d.t_warehouse), 'warehouse' : cstr(d.s_warehouse) || cstr(d.t_warehouse),
'transfer_qty' : d.transfer_qty, 'transfer_qty' : d.transfer_qty,
'serial_no' : d.serial_no, 'serial_no' : d.serial_no,
'batch_no' : d.batch_no,
'bom_no' : d.bom_no, 'bom_no' : d.bom_no,
'expense_account' : d.expense_account, 'expense_account' : d.expense_account,
'cost_center' : d.cost_center, 'cost_center' : d.cost_center,

View File

@ -510,7 +510,7 @@ class StockEntry(StockController):
d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse, d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse,
self.doctype, self.name, d.allow_zero_valuation_rate, self.doctype, self.name, d.allow_zero_valuation_rate,
currency=erpnext.get_company_currency(self.company), company=self.company, currency=erpnext.get_company_currency(self.company), company=self.company,
raise_error_if_no_rate=raise_error_if_no_rate) raise_error_if_no_rate=raise_error_if_no_rate, batch_no=d.batch_no)
d.basic_rate = flt(d.basic_rate, d.precision("basic_rate")) d.basic_rate = flt(d.basic_rate, d.precision("basic_rate"))
if d.is_process_loss: if d.is_process_loss:
@ -541,6 +541,7 @@ class StockEntry(StockController):
"posting_time": self.posting_time, "posting_time": self.posting_time,
"qty": item.s_warehouse and -1*flt(item.transfer_qty) or flt(item.transfer_qty), "qty": item.s_warehouse and -1*flt(item.transfer_qty) or flt(item.transfer_qty),
"serial_no": item.serial_no, "serial_no": item.serial_no,
"batch_no": item.batch_no,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,
"company": self.company, "company": self.company,

View File

@ -1107,6 +1107,52 @@ class TestStockEntry(ERPNextTestCase):
posting_date='2021-09-02', # backdated consumption of 2nd batch posting_date='2021-09-02', # backdated consumption of 2nd batch
purpose='Material Issue') purpose='Material Issue')
def test_multi_batch_value_diff(self):
""" Test value difference on stock entry in case of multi-batch.
| Stock entry | batch | qty | rate | value diff on SE |
| --- | --- | --- | --- | --- |
| receipt | A | 1 | 10 | 30 |
| receipt | B | 1 | 20 | |
| issue | A | -1 | 10 | -30 (to assert after submit) |
| issue | B | -1 | 20 | |
"""
from erpnext.stock.doctype.batch.test_batch import TestBatch
batch_nos = []
item_code = '_TestMultibatchFifo'
TestBatch.make_batch_item(item_code)
warehouse = '_Test Warehouse - _TC'
receipt = make_stock_entry(
item_code=item_code,
qty=1,
rate=10,
to_warehouse=warehouse,
purpose='Material Receipt',
do_not_save=True
)
receipt.append("items", frappe.copy_doc(receipt.items[0], ignore_no_copy=False).update({"basic_rate": 20}) )
receipt.save()
receipt.submit()
batch_nos.extend(row.batch_no for row in receipt.items)
self.assertEqual(receipt.value_difference, 30)
issue = make_stock_entry(
item_code=item_code,
qty=1,
from_warehouse=warehouse,
purpose='Material Issue',
do_not_save=True
)
issue.append("items", frappe.copy_doc(issue.items[0], ignore_no_copy=False))
for row, batch_no in zip(issue.items, batch_nos):
row.batch_no = batch_no
issue.save()
issue.submit()
issue.reload() # reload because reposting current voucher updates rate
self.assertEqual(issue.value_difference, -30)
def make_serialized_item(**args): def make_serialized_item(**args):
args = frappe._dict(args) args = frappe._dict(args)
se = frappe.copy_doc(test_records[0]) se = frappe.copy_doc(test_records[0])

View File

@ -1,6 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
import json
from operator import itemgetter
from uuid import uuid4
import frappe import frappe
from frappe.core.page.permission_manager.permission_manager import reset from frappe.core.page.permission_manager.permission_manager import reset
from frappe.utils import add_days, today from frappe.utils import add_days, today
@ -349,6 +353,317 @@ class TestStockLedgerEntry(ERPNextTestCase):
frappe.set_user("Administrator") frappe.set_user("Administrator")
user.remove_roles("Stock Manager") user.remove_roles("Stock Manager")
def test_batchwise_item_valuation_moving_average(self):
item, warehouses, batches = setup_item_valuation_test(valuation_method="Moving Average")
# Incoming Entries for Stock Value check
pr_entry_list = [
(item, warehouses[0], batches[0], 1, 100),
(item, warehouses[0], batches[1], 1, 50),
(item, warehouses[0], batches[0], 1, 150),
(item, warehouses[0], batches[1], 1, 100),
]
prs = create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_list)
sle_details = fetch_sle_details_for_doc_list(prs, ['stock_value'])
sv_list = [d['stock_value'] for d in sle_details]
expected_sv = [100, 150, 300, 400]
self.assertEqual(expected_sv, sv_list, "Incorrect 'Stock Value' values")
# Outgoing Entries for Stock Value Difference check
dn_entry_list = [
(item, warehouses[0], batches[1], 1, 200),
(item, warehouses[0], batches[0], 1, 200),
(item, warehouses[0], batches[1], 1, 200),
(item, warehouses[0], batches[0], 1, 200)
]
dns = create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list)
sle_details = fetch_sle_details_for_doc_list(dns, ['stock_value_difference'])
svd_list = [-1 * d['stock_value_difference'] for d in sle_details]
expected_incoming_rates = expected_abs_svd = [75, 125, 75, 125]
self.assertEqual(expected_abs_svd, svd_list, "Incorrect 'Stock Value Difference' values")
for dn, incoming_rate in zip(dns, expected_incoming_rates):
self.assertEqual(
dn.items[0].incoming_rate, incoming_rate,
"Incorrect 'Incoming Rate' values fetched for DN items"
)
def assertSLEs(self, doc, expected_sles):
""" Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line"""
sles = frappe.get_all("Stock Ledger Entry", fields=["*"],
filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled":0},
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_batchwise_item_valuation_stock_reco(self):
item, warehouses, batches = setup_item_valuation_test()
state = {
"stock_value" : 0.0,
"qty": 0.0
}
def update_invariants(exp_sles):
for sle in exp_sles:
state["stock_value"] += sle["stock_value_difference"]
state["qty"] += sle["actual_qty"]
sle["stock_value"] = state["stock_value"]
sle["qty_after_transaction"] = state["qty"]
osr1 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=10, rate=100, batch_no=batches[1])
expected_sles = [
{"actual_qty": 10, "stock_value_difference": 1000},
]
update_invariants(expected_sles)
self.assertSLEs(osr1, expected_sles)
osr2 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=13, rate=200, batch_no=batches[0])
expected_sles = [
{"actual_qty": 13, "stock_value_difference": 200*13},
]
update_invariants(expected_sles)
self.assertSLEs(osr2, expected_sles)
sr1 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=5, rate=50, batch_no=batches[1])
expected_sles = [
{"actual_qty": -10, "stock_value_difference": -10 * 100},
{"actual_qty": 5, "stock_value_difference": 250}
]
update_invariants(expected_sles)
self.assertSLEs(sr1, expected_sles)
sr2 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=20, rate=75, batch_no=batches[0])
expected_sles = [
{"actual_qty": -13, "stock_value_difference": -13 * 200},
{"actual_qty": 20, "stock_value_difference": 20 * 75}
]
update_invariants(expected_sles)
self.assertSLEs(sr2, expected_sles)
def test_batch_wise_valuation_across_warehouse(self):
item_code, warehouses, batches = setup_item_valuation_test()
source = warehouses[0]
target = warehouses[1]
unrelated_batch = make_stock_entry(item_code=item_code, target=source, batch_no=batches[1],
qty=5, rate=10)
self.assertSLEs(unrelated_batch, [
{"actual_qty": 5, "stock_value_difference": 10 * 5},
])
reciept = make_stock_entry(item_code=item_code, target=source, batch_no=batches[0], qty=5, rate=10)
self.assertSLEs(reciept, [
{"actual_qty": 5, "stock_value_difference": 10 * 5},
])
transfer = make_stock_entry(item_code=item_code, source=source, target=target, batch_no=batches[0], qty=5)
self.assertSLEs(transfer, [
{"actual_qty": -5, "stock_value_difference": -10 * 5, "warehouse": source},
{"actual_qty": 5, "stock_value_difference": 10 * 5, "warehouse": target}
])
backdated_receipt = make_stock_entry(item_code=item_code, target=source, batch_no=batches[0],
qty=5, rate=20, posting_date=add_days(today(), -1))
self.assertSLEs(backdated_receipt, [
{"actual_qty": 5, "stock_value_difference": 20 * 5},
])
# check reposted average rate in *future* transfer
self.assertSLEs(transfer, [
{"actual_qty": -5, "stock_value_difference": -15 * 5, "warehouse": source, "stock_value": 15 * 5 + 10 * 5},
{"actual_qty": 5, "stock_value_difference": 15 * 5, "warehouse": target, "stock_value": 15 * 5}
])
transfer_unrelated = make_stock_entry(item_code=item_code, source=source,
target=target, batch_no=batches[1], qty=5)
self.assertSLEs(transfer_unrelated, [
{"actual_qty": -5, "stock_value_difference": -10 * 5, "warehouse": source, "stock_value": 15 * 5},
{"actual_qty": 5, "stock_value_difference": 10 * 5, "warehouse": target, "stock_value": 15 * 5 + 10 * 5}
])
def test_intermediate_average_batch_wise_valuation(self):
""" A batch has moving average up until posting time,
check if same is respected when backdated entry is inserted in middle"""
item_code, warehouses, batches = setup_item_valuation_test()
warehouse = warehouses[0]
batch = batches[0]
yesterday = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batch,
qty=1, rate=10, posting_date=add_days(today(), -1))
self.assertSLEs(yesterday, [
{"actual_qty": 1, "stock_value_difference": 10},
])
tomorrow = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0],
qty=1, rate=30, posting_date=add_days(today(), 1))
self.assertSLEs(tomorrow, [
{"actual_qty": 1, "stock_value_difference": 30},
])
create_today = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0],
qty=1, rate=20)
self.assertSLEs(create_today, [
{"actual_qty": 1, "stock_value_difference": 20},
])
consume_today = make_stock_entry(item_code=item_code, source=warehouse, batch_no=batches[0],
qty=1)
self.assertSLEs(consume_today, [
{"actual_qty": -1, "stock_value_difference": -15},
])
consume_tomorrow = make_stock_entry(item_code=item_code, source=warehouse, batch_no=batches[0],
qty=2, posting_date=add_days(today(), 2))
self.assertSLEs(consume_tomorrow, [
{"stock_value_difference": -(30 + 15), "stock_value": 0, "qty_after_transaction": 0},
])
def test_legacy_item_valuation_stock_entry(self):
columns = [
'stock_value_difference',
'stock_value',
'actual_qty',
'qty_after_transaction',
'stock_queue',
]
item, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0)
def check_sle_details_against_expected(sle_details, expected_sle_details, detail, columns):
for i, (sle_vals, ex_sle_vals) in enumerate(zip(sle_details, expected_sle_details)):
for col, sle_val, ex_sle_val in zip(columns, sle_vals, ex_sle_vals):
if col == 'stock_queue':
sle_val = get_stock_value_from_q(sle_val)
ex_sle_val = get_stock_value_from_q(ex_sle_val)
self.assertEqual(
sle_val, ex_sle_val,
f"Incorrect {col} value on transaction #: {i} in {detail}"
)
# List used to defer assertions to prevent commits cause of error skipped rollback
details_list = []
# Test Material Receipt Entries
se_entry_list_mr = [
(item, None, warehouses[0], batches[0], 1, 50, "2021-01-21"),
(item, None, warehouses[0], batches[1], 1, 100, "2021-01-23"),
]
ses = create_stock_entry_entries_for_batchwise_item_valuation_test(
se_entry_list_mr, "Material Receipt"
)
sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0)
expected_sle_details = [
(50.0, 50.0, 1.0, 1.0, '[[1.0, 50.0]]'),
(100.0, 150.0, 1.0, 2.0, '[[1.0, 50.0], [1.0, 100.0]]'),
]
details_list.append((
sle_details, expected_sle_details,
"Material Receipt Entries", columns
))
# Test Material Issue Entries
se_entry_list_mi = [
(item, warehouses[0], None, batches[1], 1, None, "2021-01-29"),
]
ses = create_stock_entry_entries_for_batchwise_item_valuation_test(
se_entry_list_mi, "Material Issue"
)
sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0)
expected_sle_details = [
(-50.0, 100.0, -1.0, 1.0, '[[1, 100.0]]')
]
details_list.append((
sle_details, expected_sle_details,
"Material Issue Entries", columns
))
# Run assertions
for details in details_list:
check_sle_details_against_expected(*details)
def test_mixed_valuation_batches_fifo(self):
item_code, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0)
warehouse = warehouses[0]
state = {
"qty": 0.0,
"stock_value": 0.0
}
def update_invariants(exp_sles):
for sle in exp_sles:
state["stock_value"] += sle["stock_value_difference"]
state["qty"] += sle["actual_qty"]
sle["stock_value"] = state["stock_value"]
sle["qty_after_transaction"] = state["qty"]
return exp_sles
old1 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0],
qty=10, rate=10)
self.assertSLEs(old1, update_invariants([
{"actual_qty": 10, "stock_value_difference": 10*10, "stock_queue": [[10, 10]]},
]))
old2 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[1],
qty=10, rate=20)
self.assertSLEs(old2, update_invariants([
{"actual_qty": 10, "stock_value_difference": 10*20, "stock_queue": [[10, 10], [10, 20]]},
]))
old3 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0],
qty=5, rate=15)
self.assertSLEs(old3, update_invariants([
{"actual_qty": 5, "stock_value_difference": 5*15, "stock_queue": [[10, 10], [10, 20], [5, 15]]},
]))
new1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=40)
batches.append(new1.items[0].batch_no)
# assert old queue remains
self.assertSLEs(new1, update_invariants([
{"actual_qty": 10, "stock_value_difference": 10*40, "stock_queue": [[10, 10], [10, 20], [5, 15]]},
]))
new2 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=42)
batches.append(new2.items[0].batch_no)
self.assertSLEs(new2, update_invariants([
{"actual_qty": 10, "stock_value_difference": 10*42, "stock_queue": [[10, 10], [10, 20], [5, 15]]},
]))
# consume old batch as per FIFO
consume_old1 = make_stock_entry(item_code=item_code, source=warehouse, qty=15, batch_no=batches[0])
self.assertSLEs(consume_old1, update_invariants([
{"actual_qty": -15, "stock_value_difference": -10*10 - 5*20, "stock_queue": [[5, 20], [5, 15]]},
]))
# consume new batch as per batch
consume_new2 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-1])
self.assertSLEs(consume_new2, update_invariants([
{"actual_qty": -10, "stock_value_difference": -10*42, "stock_queue": [[5, 20], [5, 15]]},
]))
# finish all old batches
consume_old2 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[1])
self.assertSLEs(consume_old2, update_invariants([
{"actual_qty": -10, "stock_value_difference": -5*20 - 5*15, "stock_queue": []},
]))
# finish all new batches
consume_new1 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-2])
self.assertSLEs(consume_new1, update_invariants([
{"actual_qty": -10, "stock_value_difference": -10*40, "stock_queue": []},
]))
def create_repack_entry(**args): def create_repack_entry(**args):
args = frappe._dict(args) args = frappe._dict(args)
@ -412,3 +727,118 @@ def create_items():
make_item(d, properties=properties) make_item(d, properties=properties)
return items return items
def setup_item_valuation_test(valuation_method="FIFO", suffix=None, use_batchwise_valuation=1, batches_list=['X', 'Y']):
from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
if not suffix:
suffix = get_unique_suffix()
item = make_item(
f"IV - Test Item {valuation_method} {suffix}",
dict(valuation_method=valuation_method, has_batch_no=1, create_new_batch=1)
)
warehouses = [create_warehouse(f"IV - Test Warehouse {i}") for i in ['J', 'K']]
batches = [f"IV - Test Batch {i} {valuation_method} {suffix}" for i in batches_list]
for i, batch_id in enumerate(batches):
if not frappe.db.exists("Batch", batch_id):
ubw = use_batchwise_valuation
if isinstance(use_batchwise_valuation, (list, tuple)):
ubw = use_batchwise_valuation[i]
batch = frappe.get_doc(frappe._dict(
doctype="Batch",
batch_id=batch_id,
item=item.item_code,
use_batchwise_valuation=ubw
)
).insert()
batch.use_batchwise_valuation = ubw
batch.db_update()
return item.item_code, warehouses, batches
def create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_list):
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
prs = []
for item, warehouse, batch_no, qty, rate in pr_entry_list:
pr = make_purchase_receipt(item=item, warehouse=warehouse, qty=qty, rate=rate, batch_no=batch_no)
prs.append(pr)
return prs
def create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list):
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
dns = []
for item, warehouse, batch_no, qty, rate in dn_entry_list:
so = make_sales_order(
rate=rate,
qty=qty,
item=item,
warehouse=warehouse,
against_blanket_order=0
)
dn = make_delivery_note(so.name)
dn.items[0].batch_no = batch_no
dn.insert()
dn.submit()
dns.append(dn)
return dns
def fetch_sle_details_for_doc_list(doc_list, columns, as_dict=1):
return frappe.db.sql(f"""
SELECT { ', '.join(columns)}
FROM `tabStock Ledger Entry`
WHERE
voucher_no IN %(voucher_nos)s
and docstatus = 1
ORDER BY timestamp(posting_date, posting_time) ASC, CREATION ASC
""", dict(
voucher_nos=[doc.name for doc in doc_list]
), as_dict=as_dict)
def get_stock_value_from_q(q):
return sum(r*q for r,q in json.loads(q))
def create_stock_entry_entries_for_batchwise_item_valuation_test(se_entry_list, purpose):
ses = []
for item, source, target, batch, qty, rate, posting_date in se_entry_list:
args = dict(
item_code=item,
qty=qty,
company="_Test Company",
batch_no=batch,
posting_date=posting_date,
purpose=purpose
)
if purpose == "Material Receipt":
args.update(
dict(to_warehouse=target, rate=rate)
)
elif purpose == "Material Issue":
args.update(
dict(from_warehouse=source)
)
elif purpose == "Material Transfer":
args.update(
dict(from_warehouse=source, to_warehouse=target)
)
else:
raise ValueError(f"Invalid purpose: {purpose}")
ses.append(make_stock_entry(**args))
return ses
def get_unique_suffix():
# Used to isolate valuation sensitive
# tests to prevent future tests from failing.
return str(uuid4())[:8].upper()

View File

@ -200,7 +200,6 @@ class TestStockReconciliation(ERPNextTestCase):
def test_stock_reco_for_batch_item(self): def test_stock_reco_for_batch_item(self):
to_delete_records = [] to_delete_records = []
to_delete_serial_nos = []
# Add new serial nos # Add new serial nos
item_code = "Stock-Reco-batch-Item-1" item_code = "Stock-Reco-batch-Item-1"
@ -208,20 +207,22 @@ class TestStockReconciliation(ERPNextTestCase):
sr = create_stock_reconciliation(item_code=item_code, sr = create_stock_reconciliation(item_code=item_code,
warehouse = warehouse, qty=5, rate=200, do_not_submit=1) warehouse = warehouse, qty=5, rate=200, do_not_submit=1)
sr.save(ignore_permissions=True) sr.save()
sr.submit() sr.submit()
self.assertTrue(sr.items[0].batch_no) batch_no = sr.items[0].batch_no
self.assertTrue(batch_no)
to_delete_records.append(sr.name) to_delete_records.append(sr.name)
sr1 = create_stock_reconciliation(item_code=item_code, sr1 = create_stock_reconciliation(item_code=item_code,
warehouse = warehouse, qty=6, rate=300, batch_no=sr.items[0].batch_no) warehouse = warehouse, qty=6, rate=300, batch_no=batch_no)
args = { args = {
"item_code": item_code, "item_code": item_code,
"warehouse": warehouse, "warehouse": warehouse,
"posting_date": nowdate(), "posting_date": nowdate(),
"posting_time": nowtime(), "posting_time": nowtime(),
"batch_no": batch_no,
} }
valuation_rate = get_incoming_rate(args) valuation_rate = get_incoming_rate(args)
@ -230,7 +231,7 @@ class TestStockReconciliation(ERPNextTestCase):
sr2 = create_stock_reconciliation(item_code=item_code, sr2 = create_stock_reconciliation(item_code=item_code,
warehouse = warehouse, qty=0, rate=0, batch_no=sr.items[0].batch_no) warehouse = warehouse, qty=0, rate=0, batch_no=batch_no)
stock_value = get_stock_value_on(warehouse, nowdate(), item_code) stock_value = get_stock_value_on(warehouse, nowdate(), item_code)
self.assertEqual(stock_value, 0) self.assertEqual(stock_value, 0)

View File

@ -60,6 +60,9 @@ def add_invariant_check_fields(sles):
fifo_qty += qty fifo_qty += qty
fifo_value += qty * rate 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_qty += sle.actual_qty
balance_stock_value += sle.stock_value_difference balance_stock_value += sle.stock_value_difference
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
@ -90,6 +93,9 @@ def add_invariant_check_fields(sles):
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
if sle.batch_no:
sle.use_batchwise_valuation = frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True)
return sles return sles
@ -134,6 +140,11 @@ def get_columns():
"label": "Batch", "label": "Batch",
"options": "Batch", "options": "Batch",
}, },
{
"fieldname": "use_batchwise_valuation",
"fieldtype": "Check",
"label": "Batchwise Valuation",
},
{ {
"fieldname": "actual_qty", "fieldname": "actual_qty",
"fieldtype": "Float", "fieldtype": "Float",
@ -145,9 +156,9 @@ def get_columns():
"label": "Incoming Rate", "label": "Incoming Rate",
}, },
{ {
"fieldname": "outgoing_rate", "fieldname": "consumption_rate",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Outgoing Rate", "label": "Consumption Rate",
}, },
{ {
"fieldname": "qty_after_transaction", "fieldname": "qty_after_transaction",

View File

@ -8,7 +8,9 @@ from typing import Optional
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate
from pypika import CustomFunction
import erpnext import erpnext
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
@ -17,14 +19,13 @@ from erpnext.stock.utils import (
get_or_make_bin, get_or_make_bin,
get_valuation_method, get_valuation_method,
) )
from erpnext.stock.valuation import FIFOValuation, LIFOValuation from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero
class NegativeStockError(frappe.ValidationError): pass class NegativeStockError(frappe.ValidationError): pass
class SerialNoExistsInFutureTransaction(frappe.ValidationError): class SerialNoExistsInFutureTransaction(frappe.ValidationError):
pass pass
_exceptions = frappe.local('stockledger_exceptions')
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
from erpnext.controllers.stock_controller import future_sle_exists from erpnext.controllers.stock_controller import future_sle_exists
@ -447,6 +448,8 @@ class update_entries_after(object):
self.wh_data.qty_after_transaction = sle.qty_after_transaction self.wh_data.qty_after_transaction = sle.qty_after_transaction
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
elif sle.batch_no and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True):
self.update_batched_values(sle)
else: else:
if sle.voucher_type=="Stock Reconciliation" and not sle.batch_no: if sle.voucher_type=="Stock Reconciliation" and not sle.batch_no:
# assert # assert
@ -462,10 +465,11 @@ class update_entries_after(object):
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
else: else:
self.update_queue_values(sle) self.update_queue_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
# rounding as per precision # rounding as per precision
self.wh_data.stock_value = flt(self.wh_data.stock_value, self.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 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 self.wh_data.prev_stock_value = self.wh_data.stock_value
@ -481,6 +485,7 @@ class update_entries_after(object):
if not self.args.get("sle_id"): if not self.args.get("sle_id"):
self.update_outgoing_rate_on_transaction(sle) self.update_outgoing_rate_on_transaction(sle)
def validate_negative_stock(self, sle): def validate_negative_stock(self, sle):
""" """
validate negative stock for entries current datetime onwards validate negative stock for entries current datetime onwards
@ -629,9 +634,7 @@ class update_entries_after(object):
if not self.wh_data.valuation_rate and sle.voucher_detail_no: 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) allow_zero_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
if not allow_zero_rate: if not allow_zero_rate:
self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, self.wh_data.valuation_rate = self.get_fallback_rate(sle)
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company), company=sle.company)
def get_incoming_value_for_serial_nos(self, sle, serial_nos): def get_incoming_value_for_serial_nos(self, sle, serial_nos):
# get rate from serial nos within same company # get rate from serial nos within same company
@ -697,46 +700,70 @@ class update_entries_after(object):
if not self.wh_data.valuation_rate and sle.voucher_detail_no: 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) allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
if not allow_zero_valuation_rate: if not allow_zero_valuation_rate:
self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, self.wh_data.valuation_rate = self.get_fallback_rate(sle)
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company), company=sle.company)
def update_queue_values(self, sle): def update_queue_values(self, sle):
incoming_rate = flt(sle.incoming_rate) incoming_rate = flt(sle.incoming_rate)
actual_qty = flt(sle.actual_qty) actual_qty = flt(sle.actual_qty)
outgoing_rate = flt(sle.outgoing_rate) outgoing_rate = flt(sle.outgoing_rate)
self.wh_data.qty_after_transaction = round_off_if_near_zero(self.wh_data.qty_after_transaction + actual_qty)
if self.valuation_method == "LIFO": if self.valuation_method == "LIFO":
stock_queue = LIFOValuation(self.wh_data.stock_queue) stock_queue = LIFOValuation(self.wh_data.stock_queue)
else: else:
stock_queue = FIFOValuation(self.wh_data.stock_queue) stock_queue = FIFOValuation(self.wh_data.stock_queue)
_prev_qty, prev_stock_value = stock_queue.get_total_stock_and_value()
if actual_qty > 0: if actual_qty > 0:
stock_queue.add_stock(qty=actual_qty, rate=incoming_rate) stock_queue.add_stock(qty=actual_qty, rate=incoming_rate)
else: else:
def rate_generator() -> float: def rate_generator() -> float:
allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, 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: if not allow_zero_valuation_rate:
return get_valuation_rate(sle.item_code, sle.warehouse, return self.get_fallback_rate(sle)
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company), company=sle.company)
else: else:
return 0.0 return 0.0
stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator)
stock_qty, stock_value = stock_queue.get_total_stock_and_value() _qty, stock_value = stock_queue.get_total_stock_and_value()
stock_value_difference = stock_value - prev_stock_value
self.wh_data.stock_queue = stock_queue.state self.wh_data.stock_queue = stock_queue.state
self.wh_data.stock_value = stock_value self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + stock_value_difference)
if stock_qty:
self.wh_data.valuation_rate = stock_value / stock_qty
if not self.wh_data.stock_queue: if not self.wh_data.stock_queue:
self.wh_data.stock_queue.append([0, sle.incoming_rate or sle.outgoing_rate or self.wh_data.valuation_rate]) self.wh_data.stock_queue.append([0, sle.incoming_rate or sle.outgoing_rate or self.wh_data.valuation_rate])
if self.wh_data.qty_after_transaction:
self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction
def update_batched_values(self, sle):
incoming_rate = flt(sle.incoming_rate)
actual_qty = flt(sle.actual_qty)
self.wh_data.qty_after_transaction = round_off_if_near_zero(self.wh_data.qty_after_transaction + actual_qty)
if actual_qty > 0:
stock_value_difference = incoming_rate * actual_qty
else:
outgoing_rate = get_batch_incoming_rate(item_code=sle.item_code,
warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date,
posting_time=sle.posting_time, creation=sle.creation)
if outgoing_rate is None:
# This can *only* happen if qty available for the batch is zero.
# in such case fall back various other rates.
# future entries will correct the overall accounting as each
# batch individually uses moving average rates.
outgoing_rate = self.get_fallback_rate(sle)
stock_value_difference = outgoing_rate * actual_qty
self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + stock_value_difference)
if self.wh_data.qty_after_transaction:
self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction
def check_if_allow_zero_valuation_rate(self, voucher_type, voucher_detail_no): def check_if_allow_zero_valuation_rate(self, voucher_type, voucher_detail_no):
ref_item_dt = "" ref_item_dt = ""
@ -751,6 +778,13 @@ class update_entries_after(object):
else: else:
return 0 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, batch_no=sle.batch_no)
def get_sle_before_datetime(self, args): def get_sle_before_datetime(self, args):
"""get previous stock ledger entry before current time-bucket""" """get previous stock ledger entry before current time-bucket"""
sle = get_stock_ledger_entries(args, "<", "desc", "limit 1", for_update=False) sle = get_stock_ledger_entries(args, "<", "desc", "limit 1", for_update=False)
@ -897,22 +931,72 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None):
['item_code', 'warehouse', 'posting_date', 'posting_time', 'timestamp(posting_date, posting_time) as timestamp'], ['item_code', 'warehouse', 'posting_date', 'posting_time', 'timestamp(posting_date, posting_time) as timestamp'],
as_dict=1) as_dict=1)
def get_batch_incoming_rate(item_code, warehouse, batch_no, posting_date, posting_time, creation=None):
Timestamp = CustomFunction('timestamp', ['date', 'time'])
sle = frappe.qb.DocType("Stock Ledger Entry")
timestamp_condition = (Timestamp(sle.posting_date, sle.posting_time) < Timestamp(posting_date, posting_time))
if creation:
timestamp_condition |= (
(Timestamp(sle.posting_date, sle.posting_time) == Timestamp(posting_date, posting_time))
& (sle.creation < creation)
)
batch_details = (
frappe.qb
.from_(sle)
.select(
Sum(sle.stock_value_difference).as_("batch_value"),
Sum(sle.actual_qty).as_("batch_qty")
)
.where(
(sle.item_code == item_code)
& (sle.warehouse == warehouse)
& (sle.batch_no == batch_no)
& (sle.is_cancelled == 0)
)
.where(timestamp_condition)
).run(as_dict=True)
if batch_details and batch_details[0].batch_qty:
return batch_details[0].batch_value / batch_details[0].batch_qty
def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no,
allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True): allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True, batch_no=None):
if not company: if not company:
company = frappe.get_cached_value("Warehouse", warehouse, "company") company = frappe.get_cached_value("Warehouse", warehouse, "company")
last_valuation_rate = None
# Get moving average rate of a specific batch number
if warehouse and batch_no and frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"):
last_valuation_rate = frappe.db.sql("""
select sum(stock_value_difference) / sum(actual_qty)
from `tabStock Ledger Entry`
where
item_code = %s
AND warehouse = %s
AND batch_no = %s
AND is_cancelled = 0
AND NOT (voucher_no = %s AND voucher_type = %s)
""",
(item_code, warehouse, batch_no, voucher_no, voucher_type))
# Get valuation rate from last sle for the same item and warehouse # Get valuation rate from last sle for the same item and warehouse
last_valuation_rate = frappe.db.sql("""select valuation_rate if not last_valuation_rate or last_valuation_rate[0][0] is None:
from `tabStock Ledger Entry` force index (item_warehouse) last_valuation_rate = frappe.db.sql("""select valuation_rate
where from `tabStock Ledger Entry` force index (item_warehouse)
item_code = %s where
AND warehouse = %s item_code = %s
AND valuation_rate >= 0 AND warehouse = %s
AND is_cancelled = 0 AND valuation_rate >= 0
AND NOT (voucher_no = %s AND voucher_type = %s) AND is_cancelled = 0
order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type)) 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))
if not last_valuation_rate: if not last_valuation_rate:
# Get valuation rate from last sle for the item against any warehouse # Get valuation rate from last sle for the item against any warehouse

View File

@ -7,7 +7,7 @@ from hypothesis import strategies as st
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero
from erpnext.tests.utils import ERPNextTestCase from erpnext.tests.utils import ERPNextTestCase
qty_gen = st.floats(min_value=-1e6, max_value=1e6) qty_gen = st.floats(min_value=-1e6, max_value=1e6)
@ -113,11 +113,11 @@ class TestFIFOValuation(unittest.TestCase):
self.assertTotalQty(0) self.assertTotalQty(0)
def test_rounding_off_near_zero(self): def test_rounding_off_near_zero(self):
self.assertEqual(_round_off_if_near_zero(0), 0) self.assertEqual(round_off_if_near_zero(0), 0)
self.assertEqual(_round_off_if_near_zero(1), 1) self.assertEqual(round_off_if_near_zero(1), 1)
self.assertEqual(_round_off_if_near_zero(-1), -1) self.assertEqual(round_off_if_near_zero(-1), -1)
self.assertEqual(_round_off_if_near_zero(-1e-8), 0) self.assertEqual(round_off_if_near_zero(-1e-8), 0)
self.assertEqual(_round_off_if_near_zero(1e-8), 0) self.assertEqual(round_off_if_near_zero(1e-8), 0)
def test_totals(self): def test_totals(self):
self.queue.add_stock(1, 10) self.queue.add_stock(1, 10)

View File

@ -209,13 +209,28 @@ def _create_bin(item_code, warehouse):
@frappe.whitelist() @frappe.whitelist()
def get_incoming_rate(args, raise_error_if_no_rate=True): def get_incoming_rate(args, raise_error_if_no_rate=True):
"""Get Incoming Rate based on valuation method""" """Get Incoming Rate based on valuation method"""
from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate from erpnext.stock.stock_ledger import (
get_batch_incoming_rate,
get_previous_sle,
get_valuation_rate,
)
if isinstance(args, str): if isinstance(args, str):
args = json.loads(args) args = json.loads(args)
in_rate = 0 voucher_no = args.get('voucher_no') or args.get('name')
in_rate = None
if (args.get("serial_no") or "").strip(): if (args.get("serial_no") or "").strip():
in_rate = get_avg_purchase_rate(args.get("serial_no")) in_rate = get_avg_purchase_rate(args.get("serial_no"))
elif args.get("batch_no") and \
frappe.db.get_value("Batch", args.get("batch_no"), "use_batchwise_valuation", cache=True):
in_rate = get_batch_incoming_rate(
item_code=args.get('item_code'),
warehouse=args.get('warehouse'),
batch_no=args.get("batch_no"),
posting_date=args.get("posting_date"),
posting_time=args.get("posting_time"),
)
else: else:
valuation_method = get_valuation_method(args.get("item_code")) valuation_method = get_valuation_method(args.get("item_code"))
previous_sle = get_previous_sle(args) previous_sle = get_previous_sle(args)
@ -226,12 +241,11 @@ def get_incoming_rate(args, raise_error_if_no_rate=True):
elif valuation_method == 'Moving Average': elif valuation_method == 'Moving Average':
in_rate = previous_sle.get('valuation_rate') or 0 in_rate = previous_sle.get('valuation_rate') or 0
if not in_rate: if in_rate is None:
voucher_no = args.get('voucher_no') or args.get('name')
in_rate = get_valuation_rate(args.get('item_code'), args.get('warehouse'), in_rate = get_valuation_rate(args.get('item_code'), args.get('warehouse'),
args.get('voucher_type'), voucher_no, args.get('allow_zero_valuation'), args.get('voucher_type'), voucher_no, args.get('allow_zero_valuation'),
currency=erpnext.get_company_currency(args.get('company')), company=args.get('company'), currency=erpnext.get_company_currency(args.get('company')), company=args.get('company'),
raise_error_if_no_rate=raise_error_if_no_rate) raise_error_if_no_rate=raise_error_if_no_rate, batch_no=args.get("batch_no"))
return flt(in_rate) return flt(in_rate)
@ -247,7 +261,7 @@ def get_valuation_method(item_code):
"""get valuation method from item or default""" """get valuation method from item or default"""
val_method = frappe.db.get_value('Item', item_code, 'valuation_method', cache=True) val_method = frappe.db.get_value('Item', item_code, 'valuation_method', cache=True)
if not val_method: if not val_method:
val_method = frappe.db.get_value("Stock Settings", None, "valuation_method") or "FIFO" val_method = frappe.db.get_value("Stock Settings", None, "valuation_method", cache=True) or "FIFO"
return val_method return val_method
def get_fifo_rate(previous_stock_queue, qty): def get_fifo_rate(previous_stock_queue, qty):

View File

@ -34,7 +34,7 @@ class BinWiseValuation(ABC):
total_qty += flt(qty) total_qty += flt(qty)
total_value += flt(qty) * flt(rate) total_value += flt(qty) * flt(rate)
return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value) return round_off_if_near_zero(total_qty), round_off_if_near_zero(total_value)
def __repr__(self): def __repr__(self):
return str(self.state) return str(self.state)
@ -136,7 +136,7 @@ class FIFOValuation(BinWiseValuation):
fifo_bin = self.queue[index] fifo_bin = self.queue[index]
if qty >= fifo_bin[QTY]: if qty >= fifo_bin[QTY]:
# consume current bin # consume current bin
qty = _round_off_if_near_zero(qty - fifo_bin[QTY]) qty = round_off_if_near_zero(qty - fifo_bin[QTY])
to_consume = self.queue.pop(index) to_consume = self.queue.pop(index)
consumed_bins.append(list(to_consume)) consumed_bins.append(list(to_consume))
@ -148,7 +148,7 @@ class FIFOValuation(BinWiseValuation):
break break
else: else:
# qty found in current bin consume it and exit # qty found in current bin consume it and exit
fifo_bin[QTY] = _round_off_if_near_zero(fifo_bin[QTY] - qty) fifo_bin[QTY] = round_off_if_near_zero(fifo_bin[QTY] - qty)
consumed_bins.append([qty, fifo_bin[RATE]]) consumed_bins.append([qty, fifo_bin[RATE]])
qty = 0 qty = 0
@ -231,7 +231,7 @@ class LIFOValuation(BinWiseValuation):
stock_bin = self.stack[index] stock_bin = self.stack[index]
if qty >= stock_bin[QTY]: if qty >= stock_bin[QTY]:
# consume current bin # consume current bin
qty = _round_off_if_near_zero(qty - stock_bin[QTY]) qty = round_off_if_near_zero(qty - stock_bin[QTY])
to_consume = self.stack.pop(index) to_consume = self.stack.pop(index)
consumed_bins.append(list(to_consume)) consumed_bins.append(list(to_consume))
@ -243,14 +243,14 @@ class LIFOValuation(BinWiseValuation):
break break
else: else:
# qty found in current bin consume it and exit # qty found in current bin consume it and exit
stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty) stock_bin[QTY] = round_off_if_near_zero(stock_bin[QTY] - qty)
consumed_bins.append([qty, stock_bin[RATE]]) consumed_bins.append([qty, stock_bin[RATE]])
qty = 0 qty = 0
return consumed_bins return consumed_bins
def _round_off_if_near_zero(number: float, precision: int = 7) -> float: def round_off_if_near_zero(number: float, precision: int = 7) -> float:
"""Rounds off the number to zero only if number is close to zero for decimal """Rounds off the number to zero only if number is close to zero for decimal
specified in precision. Precision defaults to 7. specified in precision. Precision defaults to 7.
""" """