fix: stock reco test case for serial and batch bundle
diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py
index c537143..b3e0954 100644
--- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py
+++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py
@@ -88,7 +88,6 @@
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()
diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js
index 6d3af42..217f568 100644
--- a/erpnext/public/js/utils/serial_no_batch_selector.js
+++ b/erpnext/public/js/utils/serial_no_batch_selector.js
@@ -53,7 +53,6 @@
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 @@
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 @@
if (this.item?.outward) {
fields = [...this.get_filter_fields(), ...fields];
+ } else {
+ fields = [...fields, ...this.get_attach_field()];
}
fields.push({
@@ -121,6 +121,73 @@
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 @@
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';
}
diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py
index 3edcbe0..98987ae 100644
--- a/erpnext/stock/doctype/batch/batch.py
+++ b/erpnext/stock/doctype/batch/batch.py
@@ -168,7 +168,12 @@
@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 @@
"posting_date": posting_date,
"posting_time": posting_time,
"batch_no": batch_no,
+ "ignore_voucher_nos": ignore_voucher_nos,
}
)
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
index f787caa..ce5801f 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
@@ -21,6 +21,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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))
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
index 26226f3..3151c2c 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
@@ -8,7 +8,41 @@
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):
diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py
index 2162af5..ba9482a 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.py
+++ b/erpnext/stock/doctype/serial_no/serial_no.py
@@ -22,38 +22,10 @@
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 @@
)
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:
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index 17e6d83..2c8e7a7 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -744,8 +744,11 @@
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;
}
}
}
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index b1868bb..4004c00 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -181,21 +181,25 @@
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 @@
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 @@
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 @@
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 @@
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
diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
index 316b731..a04e2da 100644
--- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
@@ -694,10 +694,12 @@
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 @@
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 @@
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 @@
"do_not_submit": True,
}
)
- )
+ ).name
sr.append(
"items",
diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
index 8e148f7..8738f4a 100644
--- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
+++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
@@ -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",
diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py
index 0081ccf..77b6de1 100644
--- a/erpnext/stock/serial_batch_bundle.py
+++ b/erpnext/stock/serial_batch_bundle.py
@@ -78,6 +78,12 @@
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 @@
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 @@
)
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)
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 4694b29..01ba491 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -676,7 +676,7 @@
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 @@
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 @@
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 @@
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"))