fix: stock reco test case for serial and batch bundle

This commit is contained in:
Rohit Waghchaure 2023-05-27 19:18:03 +05:30
parent f4cfc589c6
commit 42b229435c
12 changed files with 258 additions and 70 deletions

View File

@ -88,7 +88,6 @@ class TestAssetRepair(unittest.TestCase):
self.assertEqual(stock_entry.items[0].qty, asset_repair.stock_items[0].consumed_quantity)
def test_serialized_item_consumption(self):
from erpnext.stock.doctype.serial_no.serial_no import SerialNoRequiredError
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
stock_entry = make_serialized_item()

View File

@ -53,7 +53,6 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
fieldtype: 'Data',
fieldname: 'scan_serial_no',
label: __('Scan Serial No'),
options: 'Serial No',
get_query: () => {
return {
filters: this.get_serial_no_filters()
@ -71,10 +70,9 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
if (this.item.has_batch_no) {
fields.push({
fieldtype: 'Link',
fieldtype: 'Data',
fieldname: 'scan_batch_no',
label: __('Scan Batch No'),
options: 'Batch',
get_query: () => {
return {
filters: {
@ -104,6 +102,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
if (this.item?.outward) {
fields = [...this.get_filter_fields(), ...fields];
} else {
fields = [...fields, ...this.get_attach_field()];
}
fields.push({
@ -121,6 +121,73 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
return fields;
}
get_attach_field() {
let label = this.item?.has_serial_no ? __('Serial Nos') : __('Batch Nos');
let primary_label = this.bundle
? __('Update') : __('Add');
if (this.item?.has_serial_no && this.item?.has_batch_no) {
label = __('Serial Nos / Batch Nos');
}
return [
{
fieldtype: 'Section Break',
label: __('{0} {1} via CSV File', [primary_label, label])
},
{
fieldtype: 'Button',
fieldname: 'download_csv',
label: __('Download CSV Template'),
click: () => this.download_csv_file()
},
{
fieldtype: 'Column Break',
},
{
fieldtype: 'Attach',
fieldname: 'attach_serial_batch_csv',
label: __('Attach CSV File'),
onchange: () => this.upload_csv_file()
}
]
}
download_csv_file() {
let csvFileData = ['Serial No'];
if (this.item.has_serial_no && this.item.has_batch_no) {
csvFileData = ['Serial No', 'Batch No', 'Quantity'];
} else if (this.item.has_batch_no) {
csvFileData = ['Batch No', 'Quantity'];
}
const method = `/api/method/erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.download_blank_csv_template?content=${encodeURIComponent(JSON.stringify(csvFileData))}`;
const w = window.open(frappe.urllib.get_full_url(method));
if (!w) {
frappe.msgprint(__("Please enable pop-ups"));
}
}
upload_csv_file() {
const file_path = this.dialog.get_value("attach_serial_batch_csv")
frappe.call({
method: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.upload_csv_file',
args: {
item_code: this.item.item_code,
file_path: file_path
},
callback: (r) => {
if (r.message.serial_nos && r.message.serial_nos.length) {
this.set_data(r.message.serial_nos);
} else if (r.message.batch_nos && r.message.batch_nos.length) {
this.set_data(r.message.batch_nos);
}
}
});
}
get_filter_fields() {
return [
{
@ -213,10 +280,6 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
get_auto_data() {
const { qty, based_on } = this.dialog.get_values();
if (!qty) {
frappe.throw(__('Please enter Qty to Fetch'));
}
if (!based_on) {
based_on = 'FIFO';
}

View File

@ -168,7 +168,12 @@ class Batch(Document):
@frappe.whitelist()
def get_batch_qty(
batch_no=None, warehouse=None, item_code=None, posting_date=None, posting_time=None
batch_no=None,
warehouse=None,
item_code=None,
posting_date=None,
posting_time=None,
ignore_voucher_nos=None,
):
"""Returns batch actual qty if warehouse is passed,
or returns dict of qty by warehouse if warehouse is None
@ -191,6 +196,7 @@ def get_batch_qty(
"posting_date": posting_date,
"posting_time": posting_time,
"batch_no": batch_no,
"ignore_voucher_nos": ignore_voucher_nos,
}
)

View File

@ -21,6 +21,7 @@ from frappe.utils import (
parse_json,
today,
)
from frappe.utils.csvutils import build_csv_response
from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation
from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle
@ -152,15 +153,15 @@ class SerialandBatchBundle(Document):
if self.has_serial_no:
sn_obj = SerialNoValuation(
sle=sle,
warehouse=self.item_code,
item_code=self.warehouse,
item_code=self.item_code,
warehouse=self.warehouse,
)
else:
sn_obj = BatchNoValuation(
sle=sle,
warehouse=self.item_code,
item_code=self.warehouse,
item_code=self.item_code,
warehouse=self.warehouse,
)
for d in self.entries:
@ -657,6 +658,31 @@ class SerialandBatchBundle(Document):
self.set("entries", batch_nos)
@frappe.whitelist()
def download_blank_csv_template(content):
csv_data = []
if isinstance(content, str):
content = parse_json(content)
csv_data.append(content)
csv_data.append([])
csv_data.append([])
filename = "serial_and_batch_bundle"
build_csv_response(csv_data, filename)
@frappe.whitelist()
def upload_csv_file(item_code, file_path):
serial_nos, batch_nos = [], []
serial_nos, batch_nos = get_serial_batch_from_csv(item_code, file_path)
return {
"serial_nos": serial_nos,
"batch_nos": batch_nos,
}
def get_serial_batch_from_csv(item_code, file_path):
file_path = frappe.get_site_path() + file_path
serial_nos = []
@ -669,7 +695,6 @@ def get_serial_batch_from_csv(item_code, file_path):
if serial_nos:
make_serial_nos(item_code, serial_nos)
print(batch_nos)
if batch_nos:
make_batch_nos(item_code, batch_nos)
@ -938,7 +963,7 @@ def update_serial_batch_no_ledgers(entries, child_row, parent_doc) -> object:
doc.append(
"entries",
{
"qty": 1 if doc.type_of_transaction == "Inward" else -1,
"qty": d.get("qty") * (1 if doc.type_of_transaction == "Inward" else -1),
"warehouse": d.get("warehouse"),
"batch_no": d.get("batch_no"),
"serial_no": d.get("serial_no"),
@ -1272,6 +1297,9 @@ def get_available_batches(kwargs):
else:
query = query.orderby(batch_table.creation)
if kwargs.get("ignore_voucher_nos"):
query = query.where(stock_ledger_entry.voucher_no.notin(kwargs.get("ignore_voucher_nos")))
data = query.run(as_dict=True)
data = list(filter(lambda x: x.qty > 0, data))

View File

@ -8,7 +8,41 @@ from erpnext.stock.serial_batch_bundle import get_batch_nos, get_serial_nos
class TestSerialandBatchBundle(FrappeTestCase):
pass
def test_inward_serial_batch_bundle(self):
pass
def test_outward_serial_batch_bundle(self):
pass
def test_old_batch_valuation(self):
pass
def test_old_batch_batchwise_valuation(self):
pass
def test_old_serial_no_valuation(self):
pass
def test_batch_not_belong_to_serial_no(self):
pass
def test_serial_no_not_exists(self):
pass
def test_serial_no_item(self):
pass
def test_serial_no_not_required(self):
pass
def test_serial_no_required(self):
pass
def test_batch_no_not_required(self):
pass
def test_batch_no_required(self):
pass
def get_batch_from_bundle(bundle):

View File

@ -22,38 +22,10 @@ class SerialNoCannotCannotChangeError(ValidationError):
pass
class SerialNoNotRequiredError(ValidationError):
pass
class SerialNoRequiredError(ValidationError):
pass
class SerialNoQtyError(ValidationError):
pass
class SerialNoItemError(ValidationError):
pass
class SerialNoWarehouseError(ValidationError):
pass
class SerialNoBatchError(ValidationError):
pass
class SerialNoNotExistsError(ValidationError):
pass
class SerialNoDuplicateError(ValidationError):
pass
class SerialNo(StockController):
def __init__(self, *args, **kwargs):
super(SerialNo, self).__init__(*args, **kwargs)
@ -69,6 +41,15 @@ class SerialNo(StockController):
)
self.set_maintenance_status()
self.validate_warehouse()
def validate_warehouse(self):
if not self.get("__islocal"):
item_code, warehouse = frappe.db.get_value("Serial No", self.name, ["item_code", "warehouse"])
if not self.via_stock_ledger and item_code != self.item_code:
frappe.throw(_("Item Code cannot be changed for Serial No."), SerialNoCannotCannotChangeError)
if not self.via_stock_ledger and warehouse != self.warehouse:
frappe.throw(_("Warehouse cannot be changed for Serial No."), SerialNoCannotCannotChangeError)
def set_maintenance_status(self):
if not self.warranty_expiry_date and not self.amc_expiry_date:

View File

@ -744,8 +744,11 @@ frappe.ui.form.on('Stock Entry Detail', {
no_batch_serial_number_value = !d.batch_no;
}
if (no_batch_serial_number_value && !frappe.flags.hide_serial_batch_dialog) {
if (no_batch_serial_number_value && !frappe.flags.hide_serial_batch_dialog && !frappe.flags.dialog_set) {
frappe.flags.dialog_set = true;
erpnext.stock.select_batch_and_serial_no(frm, d);
} else {
frappe.flags.dialog_set = false;
}
}
}

View File

@ -181,21 +181,25 @@ class StockReconciliation(StockController):
bundle_doc.flags.ignore_permissions = True
bundle_doc.save()
item.serial_and_batch_bundle = bundle_doc.name
elif item.serial_and_batch_bundle:
pass
elif item.serial_and_batch_bundle and not item.qty and not item.valuation_rate:
bundle_doc = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
item.qty = bundle_doc.total_qty
item.valuation_rate = bundle_doc.avg_rate
def remove_items_with_no_change(self):
"""Remove items if qty or rate is not changed"""
self.difference_amount = 0.0
def _changed(item):
if item.current_serial_and_batch_bundle:
self.calculate_difference_amount(item, frappe._dict({}))
return True
item_dict = get_stock_balance_for(
item.item_code, item.warehouse, self.posting_date, self.posting_time, batch_no=item.batch_no
)
if item.current_serial_and_batch_bundle:
return True
if (item.qty is None or item.qty == item_dict.get("qty")) and (
item.valuation_rate is None or item.valuation_rate == item_dict.get("rate")
):
@ -210,11 +214,7 @@ class StockReconciliation(StockController):
item.current_qty = item_dict.get("qty")
item.current_valuation_rate = item_dict.get("rate")
self.difference_amount += flt(item.qty, item.precision("qty")) * flt(
item.valuation_rate or item_dict.get("rate"), item.precision("valuation_rate")
) - flt(item_dict.get("qty"), item.precision("qty")) * flt(
item_dict.get("rate"), item.precision("valuation_rate")
)
self.calculate_difference_amount(item, item_dict)
return True
items = list(filter(lambda d: _changed(d), self.items))
@ -231,6 +231,13 @@ class StockReconciliation(StockController):
item.idx = i + 1
frappe.msgprint(_("Removed items with no change in quantity or value."))
def calculate_difference_amount(self, item, item_dict):
self.difference_amount += flt(item.qty, item.precision("qty")) * flt(
item.valuation_rate or item_dict.get("rate"), item.precision("valuation_rate")
) - flt(item_dict.get("qty"), item.precision("qty")) * flt(
item_dict.get("rate"), item.precision("valuation_rate")
)
def validate_data(self):
def _get_msg(row_num, msg):
return _("Row # {0}:").format(row_num + 1) + " " + msg
@ -643,7 +650,14 @@ class StockReconciliation(StockController):
sl_entries = []
for row in self.items:
if not (row.item_code == item_code and row.batch_no == batch_no):
if (
not (row.item_code == item_code and row.batch_no == batch_no)
and not row.serial_and_batch_bundle
):
continue
if row.current_serial_and_batch_bundle:
self.recalculate_qty_for_serial_and_batch_bundle(row)
continue
current_qty = get_batch_qty_for_stock_reco(
@ -677,6 +691,27 @@ class StockReconciliation(StockController):
if sl_entries:
self.make_sl_entries(sl_entries)
def recalculate_qty_for_serial_and_batch_bundle(self, row):
doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle)
precision = doc.entries[0].precision("qty")
for d in doc.entries:
qty = (
get_batch_qty(
d.batch_no,
doc.warehouse,
posting_date=doc.posting_date,
posting_time=doc.posting_time,
ignore_voucher_nos=[doc.voucher_no],
)
or 0
) * -1
if flt(d.qty, precision) == flt(qty, precision):
continue
d.db_set("qty", qty)
def get_batch_qty_for_stock_reco(
item_code, warehouse, batch_no, posting_date, posting_time, voucher_no

View File

@ -694,10 +694,12 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
item_code=item_code, posting_time="09:00:00", target=warehouse, qty=100, basic_rate=700
)
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
# Removed 50 Qty, Balace Qty 50
se2 = make_stock_entry(
item_code=item_code,
batch_no=se1.items[0].batch_no,
batch_no=batch_no,
posting_time="10:00:00",
source=warehouse,
qty=50,
@ -709,15 +711,23 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
item_code=item_code,
posting_time="11:00:00",
warehouse=warehouse,
batch_no=se1.items[0].batch_no,
batch_no=batch_no,
qty=100,
rate=100,
)
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"is_cancelled": 0, "voucher_no": stock_reco.name, "actual_qty": ("<", 0)},
fields=["actual_qty"],
)
self.assertEqual(flt(sle[0].actual_qty), flt(-50.0))
# Removed 50 Qty, Balace Qty 50
make_stock_entry(
item_code=item_code,
batch_no=se1.items[0].batch_no,
batch_no=batch_no,
posting_time="12:00:00",
source=warehouse,
qty=50,
@ -741,12 +751,20 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0},
fields=["qty_after_transaction"],
fields=["qty_after_transaction", "actual_qty", "voucher_type", "voucher_no"],
order_by="posting_time desc, creation desc",
)
self.assertEqual(flt(sle[0].qty_after_transaction), flt(50.0))
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"is_cancelled": 0, "voucher_no": stock_reco.name, "actual_qty": ("<", 0)},
fields=["actual_qty"],
)
self.assertEqual(flt(sle[0].actual_qty), flt(-100.0))
def test_update_stock_reconciliation_while_reposting(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@ -914,7 +932,7 @@ def create_stock_reconciliation(**args):
"do_not_submit": True,
}
)
)
).name
sr.append(
"items",

View File

@ -17,6 +17,7 @@
"amount",
"allow_zero_valuation_rate",
"serial_no_and_batch_section",
"add_serial_batch_bundle",
"serial_and_batch_bundle",
"batch_no",
"column_break_11",
@ -203,11 +204,16 @@
"label": "Current Serial / Batch Bundle",
"options": "Serial and Batch Bundle",
"read_only": 1
},
{
"fieldname": "add_serial_batch_bundle",
"fieldtype": "Button",
"label": "Add Serial / Batch No"
}
],
"istable": 1,
"links": [],
"modified": "2023-05-09 18:42:19.224916",
"modified": "2023-05-27 17:35:31.026852",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation Item",

View File

@ -78,6 +78,12 @@ class SerialBatchBundle:
self.set_serial_and_batch_bundle(sn_doc)
def validate_actual_qty(self, sn_doc):
precision = sn_doc.precision("total_qty")
if flt(sn_doc.total_qty, precision) != flt(self.sle.actual_qty, precision):
msg = f"Total qty {flt(sn_doc.total_qty, precision)} of Serial and Batch Bundle {sn_doc.name} is not equal to Actual Qty {flt(self.sle.actual_qty, precision)} in the {self.sle.voucher_type} {self.sle.voucher_no}"
frappe.throw(_(msg))
def validate_item(self):
msg = ""
if self.sle.actual_qty > 0:
@ -214,6 +220,8 @@ class SerialBatchBundle:
def submit_serial_and_batch_bundle(self):
doc = frappe.get_doc("Serial and Batch Bundle", self.sle.serial_and_batch_bundle)
self.validate_actual_qty(doc)
doc.flags.ignore_voucher_validation = True
doc.submit()
@ -426,9 +434,6 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
)
else:
entries = self.get_batch_no_ledgers()
if frappe.flags.add_breakpoint:
breakpoint()
self.batch_avg_rate = defaultdict(float)
self.available_qty = defaultdict(float)
self.stock_value_differece = defaultdict(float)

View File

@ -676,7 +676,7 @@ class update_entries_after(object):
if (
sle.voucher_type == "Stock Reconciliation"
and sle.batch_no
and (sle.batch_no or (sle.has_batch_no and sle.serial_and_batch_bundle))
and sle.voucher_detail_no
and sle.actual_qty < 0
):
@ -734,9 +734,17 @@ class update_entries_after(object):
self.update_outgoing_rate_on_transaction(sle)
def reset_actual_qty_for_stock_reco(self, sle):
current_qty = frappe.get_cached_value(
"Stock Reconciliation Item", sle.voucher_detail_no, "current_qty"
)
if sle.serial_and_batch_bundle:
current_qty = frappe.get_cached_value(
"Serial and Batch Bundle", sle.serial_and_batch_bundle, "total_qty"
)
if current_qty is not None:
current_qty = abs(current_qty)
else:
current_qty = frappe.get_cached_value(
"Stock Reconciliation Item", sle.voucher_detail_no, "current_qty"
)
if current_qty:
sle.actual_qty = current_qty * -1
@ -1524,7 +1532,7 @@ def update_qty_in_future_sle(args, allow_negative_stock=False):
next_stock_reco_detail = get_next_stock_reco(args)
if next_stock_reco_detail:
detail = next_stock_reco_detail[0]
if detail.batch_no:
if detail.batch_no or (detail.serial_and_batch_bundle and detail.has_batch_no):
regenerate_sle_for_batch_stock_reco(detail)
# add condition to update SLEs before this date & time
@ -1602,7 +1610,9 @@ def get_next_stock_reco(kwargs):
sle.voucher_no,
sle.item_code,
sle.batch_no,
sle.serial_and_batch_bundle,
sle.actual_qty,
sle.has_batch_no,
)
.where(
(sle.item_code == kwargs.get("item_code"))