mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-19 06:45:11 +00:00
feat: optional to reconcile all serial nos / batches in stock reconciliation (backport #41696) (#41713)
* feat: optional to reconcile all serial nos / batches in stock reconciliation (#41696)
feat: optional to reconcile all serial/batch
(cherry picked from commit ee846f5950)
# Conflicts:
# erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
* chore: fix conflicts
---------
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
This commit is contained in:
@@ -167,6 +167,7 @@ class TestPickList(FrappeTestCase):
|
|||||||
"item_code": "_Test Serialized Item",
|
"item_code": "_Test Serialized Item",
|
||||||
"warehouse": "_Test Warehouse - _TC",
|
"warehouse": "_Test Warehouse - _TC",
|
||||||
"valuation_rate": 100,
|
"valuation_rate": 100,
|
||||||
|
"reconcile_all_serial_batch": 1,
|
||||||
"qty": 5,
|
"qty": 5,
|
||||||
"serial_and_batch_bundle": make_serial_batch_bundle(
|
"serial_and_batch_bundle": make_serial_batch_bundle(
|
||||||
frappe._dict(
|
frappe._dict(
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ frappe.ui.form.on("Stock Reconciliation", {
|
|||||||
posting_date: frm.doc.posting_date,
|
posting_date: frm.doc.posting_date,
|
||||||
posting_time: frm.doc.posting_time,
|
posting_time: frm.doc.posting_time,
|
||||||
batch_no: d.batch_no,
|
batch_no: d.batch_no,
|
||||||
|
row: d,
|
||||||
},
|
},
|
||||||
callback: function (r) {
|
callback: function (r) {
|
||||||
const row = frappe.model.get_doc(cdt, cdn);
|
const row = frappe.model.get_doc(cdt, cdn);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _, bold, msgprint
|
from frappe import _, bold, json, msgprint
|
||||||
from frappe.query_builder.functions import CombineDatetime, Sum
|
from frappe.query_builder.functions import CombineDatetime, Sum
|
||||||
from frappe.utils import add_to_date, cint, cstr, flt
|
from frappe.utils import add_to_date, cint, cstr, flt
|
||||||
|
|
||||||
@@ -162,6 +162,11 @@ class StockReconciliation(StockController):
|
|||||||
def set_current_serial_and_batch_bundle(self, voucher_detail_no=None, save=False) -> None:
|
def set_current_serial_and_batch_bundle(self, voucher_detail_no=None, save=False) -> None:
|
||||||
"""Set Serial and Batch Bundle for each item"""
|
"""Set Serial and Batch Bundle for each item"""
|
||||||
for item in self.items:
|
for item in self.items:
|
||||||
|
if not item.reconcile_all_serial_batch and item.serial_and_batch_bundle:
|
||||||
|
bundle = self.get_bundle_for_specific_serial_batch(item)
|
||||||
|
item.current_serial_and_batch_bundle = bundle
|
||||||
|
continue
|
||||||
|
|
||||||
if not save and item.use_serial_batch_fields:
|
if not save and item.use_serial_batch_fields:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -273,6 +278,75 @@ class StockReconciliation(StockController):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_bundle_for_specific_serial_batch(self, row) -> str:
|
||||||
|
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
|
||||||
|
|
||||||
|
if row.current_serial_and_batch_bundle and not self.has_change_in_serial_batch(row):
|
||||||
|
return row.current_serial_and_batch_bundle
|
||||||
|
|
||||||
|
cls_obj = SerialBatchCreation(
|
||||||
|
{
|
||||||
|
"type_of_transaction": "Outward",
|
||||||
|
"serial_and_batch_bundle": row.serial_and_batch_bundle,
|
||||||
|
"item_code": row.get("item_code"),
|
||||||
|
"warehouse": row.get("warehouse"),
|
||||||
|
"posting_date": self.posting_date,
|
||||||
|
"posting_time": self.posting_time,
|
||||||
|
"do_not_save": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
reco_obj = cls_obj.duplicate_package()
|
||||||
|
|
||||||
|
total_current_qty = 0.0
|
||||||
|
for entry in reco_obj.entries:
|
||||||
|
if not entry.batch_no or entry.serial_no:
|
||||||
|
total_current_qty += entry.qty
|
||||||
|
entry.qty *= -1
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_qty = get_batch_qty(
|
||||||
|
entry.batch_no,
|
||||||
|
row.warehouse,
|
||||||
|
row.item_code,
|
||||||
|
posting_date=self.posting_date,
|
||||||
|
posting_time=self.posting_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_current_qty += current_qty
|
||||||
|
entry.qty = current_qty * -1
|
||||||
|
|
||||||
|
reco_obj.flags.ignore_validate = True
|
||||||
|
reco_obj.save()
|
||||||
|
|
||||||
|
row.current_qty = total_current_qty
|
||||||
|
|
||||||
|
return reco_obj.name
|
||||||
|
|
||||||
|
def has_change_in_serial_batch(self, row) -> bool:
|
||||||
|
bundles = {row.serial_and_batch_bundle: [], row.current_serial_and_batch_bundle: []}
|
||||||
|
|
||||||
|
data = frappe.get_all(
|
||||||
|
"Serial and Batch Entry",
|
||||||
|
fields=["serial_no", "batch_no", "parent"],
|
||||||
|
filters={"parent": ("in", [row.serial_and_batch_bundle, row.current_serial_and_batch_bundle])},
|
||||||
|
order_by="idx",
|
||||||
|
)
|
||||||
|
|
||||||
|
for d in data:
|
||||||
|
bundles[d.parent].append(d.serial_no or d.batch_no)
|
||||||
|
|
||||||
|
diff = set(bundles[row.serial_and_batch_bundle]) - set(bundles[row.current_serial_and_batch_bundle])
|
||||||
|
|
||||||
|
if diff:
|
||||||
|
bundle = row.current_serial_and_batch_bundle
|
||||||
|
row.current_serial_and_batch_bundle = None
|
||||||
|
frappe.delete_doc("Serial and Batch Bundle", bundle)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def set_new_serial_and_batch_bundle(self):
|
def set_new_serial_and_batch_bundle(self):
|
||||||
for item in self.items:
|
for item in self.items:
|
||||||
if item.use_serial_batch_fields:
|
if item.use_serial_batch_fields:
|
||||||
@@ -340,6 +414,7 @@ class StockReconciliation(StockController):
|
|||||||
self.posting_time,
|
self.posting_time,
|
||||||
batch_no=item.batch_no,
|
batch_no=item.batch_no,
|
||||||
inventory_dimensions_dict=inventory_dimensions_dict,
|
inventory_dimensions_dict=inventory_dimensions_dict,
|
||||||
|
row=item,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -840,6 +915,7 @@ class StockReconciliation(StockController):
|
|||||||
row.warehouse,
|
row.warehouse,
|
||||||
self.posting_date,
|
self.posting_date,
|
||||||
self.posting_time,
|
self.posting_time,
|
||||||
|
row=row,
|
||||||
)
|
)
|
||||||
|
|
||||||
current_qty = item_dict.get("qty")
|
current_qty = item_dict.get("qty")
|
||||||
@@ -1166,11 +1242,18 @@ def get_stock_balance_for(
|
|||||||
batch_no: str | None = None,
|
batch_no: str | None = None,
|
||||||
with_valuation_rate: bool = True,
|
with_valuation_rate: bool = True,
|
||||||
inventory_dimensions_dict=None,
|
inventory_dimensions_dict=None,
|
||||||
|
row=None,
|
||||||
):
|
):
|
||||||
frappe.has_permission("Stock Reconciliation", "write", throw=True)
|
frappe.has_permission("Stock Reconciliation", "write", throw=True)
|
||||||
|
|
||||||
item_dict = frappe.get_cached_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1)
|
item_dict = frappe.get_cached_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1)
|
||||||
|
|
||||||
|
if isinstance(row, str):
|
||||||
|
row = json.loads(row)
|
||||||
|
|
||||||
|
if isinstance(row, dict):
|
||||||
|
row = frappe._dict(row)
|
||||||
|
|
||||||
if not item_dict:
|
if not item_dict:
|
||||||
# In cases of data upload to Items table
|
# In cases of data upload to Items table
|
||||||
msg = _("Item {} does not exist.").format(item_code)
|
msg = _("Item {} does not exist.").format(item_code)
|
||||||
@@ -1188,7 +1271,7 @@ def get_stock_balance_for(
|
|||||||
"qty": 0,
|
"qty": 0,
|
||||||
"rate": 0,
|
"rate": 0,
|
||||||
"serial_nos": None,
|
"serial_nos": None,
|
||||||
"use_serial_batch_fields": use_serial_batch_fields,
|
"use_serial_batch_fields": row.use_serial_batch_fields if row else use_serial_batch_fields,
|
||||||
}
|
}
|
||||||
|
|
||||||
# TODO: fetch only selected batch's values
|
# TODO: fetch only selected batch's values
|
||||||
@@ -1214,7 +1297,7 @@ def get_stock_balance_for(
|
|||||||
"qty": qty,
|
"qty": qty,
|
||||||
"rate": rate,
|
"rate": rate,
|
||||||
"serial_nos": serial_nos,
|
"serial_nos": serial_nos,
|
||||||
"use_serial_batch_fields": use_serial_batch_fields,
|
"use_serial_batch_fields": row.use_serial_batch_fields if row else use_serial_batch_fields,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1070,6 +1070,103 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
|||||||
self.assertTrue(sr.items[0].serial_and_batch_bundle)
|
self.assertTrue(sr.items[0].serial_and_batch_bundle)
|
||||||
self.assertFalse(sr.items[0].current_serial_and_batch_bundle)
|
self.assertFalse(sr.items[0].current_serial_and_batch_bundle)
|
||||||
|
|
||||||
|
def test_not_reconcile_all_batch(self):
|
||||||
|
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||||
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||||
|
|
||||||
|
item = self.make_item(
|
||||||
|
"Test Batch Item Not Reconcile All Serial Batch",
|
||||||
|
{
|
||||||
|
"is_stock_item": 1,
|
||||||
|
"has_batch_no": 1,
|
||||||
|
"create_new_batch": 1,
|
||||||
|
"batch_number_series": "TEST-BATCH-NRALL-SRCOSRWFEE-.###",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
warehouse = "_Test Warehouse - _TC"
|
||||||
|
|
||||||
|
batches = []
|
||||||
|
for qty in [10, 20, 30]:
|
||||||
|
se = make_stock_entry(
|
||||||
|
item_code=item.name,
|
||||||
|
target=warehouse,
|
||||||
|
qty=qty,
|
||||||
|
basic_rate=100 + qty,
|
||||||
|
posting_date=nowdate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
|
||||||
|
batches.append(frappe._dict({"batch_no": batch_no, "qty": qty}))
|
||||||
|
|
||||||
|
sr = create_stock_reconciliation(
|
||||||
|
item_code=item.name,
|
||||||
|
warehouse=warehouse,
|
||||||
|
qty=100,
|
||||||
|
rate=1000,
|
||||||
|
reconcile_all_serial_batch=0,
|
||||||
|
batch_no=batches[0].batch_no,
|
||||||
|
)
|
||||||
|
|
||||||
|
sr.reload()
|
||||||
|
current_sabb = sr.items[0].current_serial_and_batch_bundle
|
||||||
|
doc = frappe.get_doc("Serial and Batch Bundle", current_sabb)
|
||||||
|
for row in doc.entries:
|
||||||
|
self.assertEqual(row.batch_no, batches[0].batch_no)
|
||||||
|
self.assertEqual(row.qty, batches[0].qty * -1)
|
||||||
|
|
||||||
|
batch_qty = get_batch_qty(batches[0].batch_no, warehouse, item.name)
|
||||||
|
self.assertEqual(batch_qty, 100)
|
||||||
|
|
||||||
|
def test_not_reconcile_all_serial_nos(self):
|
||||||
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||||
|
from erpnext.stock.utils import get_incoming_rate
|
||||||
|
|
||||||
|
item = self.make_item(
|
||||||
|
"Test Serial NO Item Not Reconcile All Serial Batch",
|
||||||
|
{
|
||||||
|
"is_stock_item": 1,
|
||||||
|
"has_serial_no": 1,
|
||||||
|
"serial_no_series": "SNN-TEST-BATCH-NRALL-S-.###",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
warehouse = "_Test Warehouse - _TC"
|
||||||
|
|
||||||
|
serial_nos = []
|
||||||
|
for qty in [5, 5, 5]:
|
||||||
|
se = make_stock_entry(
|
||||||
|
item_code=item.name,
|
||||||
|
target=warehouse,
|
||||||
|
qty=qty,
|
||||||
|
basic_rate=100 + qty,
|
||||||
|
posting_date=nowdate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
serial_nos.extend(get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle))
|
||||||
|
|
||||||
|
sr = create_stock_reconciliation(
|
||||||
|
item_code=item.name,
|
||||||
|
warehouse=warehouse,
|
||||||
|
qty=5,
|
||||||
|
rate=1000,
|
||||||
|
reconcile_all_serial_batch=0,
|
||||||
|
serial_no=serial_nos[0:5],
|
||||||
|
)
|
||||||
|
|
||||||
|
sr.reload()
|
||||||
|
current_sabb = sr.items[0].current_serial_and_batch_bundle
|
||||||
|
doc = frappe.get_doc("Serial and Batch Bundle", current_sabb)
|
||||||
|
for row in doc.entries:
|
||||||
|
self.assertEqual(row.serial_no, serial_nos[row.idx - 1])
|
||||||
|
|
||||||
|
sabb = sr.items[0].serial_and_batch_bundle
|
||||||
|
doc = frappe.get_doc("Serial and Batch Bundle", sabb)
|
||||||
|
for row in doc.entries:
|
||||||
|
self.assertEqual(row.qty, 1)
|
||||||
|
self.assertAlmostEqual(row.incoming_rate, 1000.00)
|
||||||
|
self.assertEqual(row.serial_no, serial_nos[row.idx - 1])
|
||||||
|
|
||||||
|
|
||||||
def create_batch_item_with_batch(item_name, batch_id):
|
def create_batch_item_with_batch(item_name, batch_id):
|
||||||
batch_item_doc = create_item(item_name, is_stock_item=1)
|
batch_item_doc = create_item(item_name, is_stock_item=1)
|
||||||
@@ -1193,12 +1290,16 @@ def create_stock_reconciliation(**args):
|
|||||||
)
|
)
|
||||||
).name
|
).name
|
||||||
|
|
||||||
|
if args.reconcile_all_serial_batch is None:
|
||||||
|
args.reconcile_all_serial_batch = 1
|
||||||
|
|
||||||
sr.append(
|
sr.append(
|
||||||
"items",
|
"items",
|
||||||
{
|
{
|
||||||
"item_code": args.item_code or "_Test Item",
|
"item_code": args.item_code or "_Test Item",
|
||||||
"warehouse": args.warehouse or "_Test Warehouse - _TC",
|
"warehouse": args.warehouse or "_Test Warehouse - _TC",
|
||||||
"qty": args.qty,
|
"qty": args.qty,
|
||||||
|
"reconcile_all_serial_batch": args.reconcile_all_serial_batch,
|
||||||
"valuation_rate": args.rate,
|
"valuation_rate": args.rate,
|
||||||
"serial_no": args.serial_no if args.use_serial_batch_fields else None,
|
"serial_no": args.serial_no if args.use_serial_batch_fields else None,
|
||||||
"batch_no": args.batch_no if args.use_serial_batch_fields else None,
|
"batch_no": args.batch_no if args.use_serial_batch_fields else None,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"serial_no_and_batch_section",
|
"serial_no_and_batch_section",
|
||||||
"add_serial_batch_bundle",
|
"add_serial_batch_bundle",
|
||||||
"use_serial_batch_fields",
|
"use_serial_batch_fields",
|
||||||
|
"reconcile_all_serial_batch",
|
||||||
"column_break_11",
|
"column_break_11",
|
||||||
"serial_and_batch_bundle",
|
"serial_and_batch_bundle",
|
||||||
"current_serial_and_batch_bundle",
|
"current_serial_and_batch_bundle",
|
||||||
@@ -243,11 +244,18 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "column_break_eefq",
|
"fieldname": "column_break_eefq",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"depends_on": "eval:!doc.use_serial_batch_fields",
|
||||||
|
"fieldname": "reconcile_all_serial_batch",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Reconcile All Serial Nos / Batches"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-02-04 16:19:44.576022",
|
"modified": "2024-05-30 23:20:00.947243",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Stock",
|
"module": "Stock",
|
||||||
"name": "Stock Reconciliation Item",
|
"name": "Stock Reconciliation Item",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class StockReconciliationItem(Document):
|
|||||||
parenttype: DF.Data
|
parenttype: DF.Data
|
||||||
qty: DF.Float
|
qty: DF.Float
|
||||||
quantity_difference: DF.ReadOnly | None
|
quantity_difference: DF.ReadOnly | None
|
||||||
|
reconcile_all_serial_batch: DF.Check
|
||||||
serial_and_batch_bundle: DF.Link | None
|
serial_and_batch_bundle: DF.Link | None
|
||||||
serial_no: DF.LongText | None
|
serial_no: DF.LongText | None
|
||||||
use_serial_batch_fields: DF.Check
|
use_serial_batch_fields: DF.Check
|
||||||
|
|||||||
@@ -848,10 +848,14 @@ class SerialBatchCreation:
|
|||||||
new_package.docstatus = 0
|
new_package.docstatus = 0
|
||||||
new_package.warehouse = self.warehouse
|
new_package.warehouse = self.warehouse
|
||||||
new_package.voucher_no = ""
|
new_package.voucher_no = ""
|
||||||
new_package.posting_date = today()
|
new_package.posting_date = self.posting_date if hasattr(self, "posting_date") else today()
|
||||||
new_package.posting_time = nowtime()
|
new_package.posting_time = self.posting_time if hasattr(self, "posting_time") else nowtime()
|
||||||
new_package.type_of_transaction = self.type_of_transaction
|
new_package.type_of_transaction = self.type_of_transaction
|
||||||
new_package.returned_against = self.get("returned_against")
|
new_package.returned_against = self.get("returned_against")
|
||||||
|
|
||||||
|
if self.get("do_not_save"):
|
||||||
|
return new_package
|
||||||
|
|
||||||
new_package.save()
|
new_package.save()
|
||||||
|
|
||||||
self.serial_and_batch_bundle = new_package.name
|
self.serial_and_batch_bundle = new_package.name
|
||||||
|
|||||||
Reference in New Issue
Block a user