mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 07:19:31 +00:00
Compare commits
20 Commits
develop
...
codex/seri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
653ef6eff2 | ||
|
|
acbefcd603 | ||
|
|
445a30ba60 | ||
|
|
fca005c935 | ||
|
|
e51c01628d | ||
|
|
766e51ae58 | ||
|
|
3acaa55db9 | ||
|
|
dbfec6e9fb | ||
|
|
1426a098f1 | ||
|
|
92b6d708d8 | ||
|
|
fa244a3615 | ||
|
|
cdb12ecf9d | ||
|
|
86489d6905 | ||
|
|
d81fe03776 | ||
|
|
f80cac927d | ||
|
|
687c7d55ba | ||
|
|
6f2cf3bf91 | ||
|
|
48818c963a | ||
|
|
c67a57d9bd | ||
|
|
ca880f6be7 |
@@ -818,7 +818,7 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
|
||||
|
||||
create_batch_item_with_batch("_BATCH ITEM", "TestBatch 01")
|
||||
batch_no = create_batch_item_with_batch("_BATCH ITEM", "TestBatch 01")
|
||||
item = frappe.get_doc("Item", "_BATCH ITEM")
|
||||
|
||||
se = make_stock_entry(
|
||||
@@ -826,12 +826,10 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
item_code="_BATCH ITEM",
|
||||
qty=2,
|
||||
basic_rate=100,
|
||||
batch_no="TestBatch 01",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
pos_inv1 = create_pos_invoice(
|
||||
item=item.name, rate=300, qty=1, do_not_submit=1, batch_no="TestBatch 01"
|
||||
)
|
||||
pos_inv1 = create_pos_invoice(item=item.name, rate=300, qty=1, do_not_submit=1, batch_no=batch_no)
|
||||
pos_inv1.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "amount": 300},
|
||||
@@ -849,7 +847,7 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
"voucher_no": pos_inv2.name,
|
||||
"qty": 2,
|
||||
"avg_rate": 300,
|
||||
"batches": frappe._dict({"TestBatch 01": 2}),
|
||||
"batches": frappe._dict({batch_no: 2}),
|
||||
"type_of_transaction": "Outward",
|
||||
"company": pos_inv2.company,
|
||||
}
|
||||
@@ -925,6 +923,7 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pos_inv.submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"allow_negative_stock": 0})
|
||||
def test_bundle_stock_availability_validation(self):
|
||||
from erpnext.accounts.doctype.pos_invoice.pos_invoice import ProductBundleStockValidationError
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
|
||||
@@ -36,6 +36,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
|
||||
make_serial_batch_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.stock.tests.test_utils import StockTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -2643,25 +2644,8 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
batch_no = "BATCH-PI-BNU-TPRBI-0001"
|
||||
serial_nos = ["SNU-PI-TPRSI-0001", "SNU-PI-TPRSI-0002", "SNU-PI-TPRSI-0003"]
|
||||
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": batch_item,
|
||||
}
|
||||
).insert()
|
||||
|
||||
for serial_no in serial_nos:
|
||||
if not frappe.db.exists("Serial No", serial_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": serial_item,
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
batch_no = SerialBatchIdentity("Batch").resolve(batch_item, [batch_no], create=True)[0]
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(serial_item, serial_nos, create=True)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
item_code=batch_item,
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PurchaseInvoiceItem(Document):
|
||||
class PurchaseInvoiceItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
from erpnext.assets.doctype.asset.depreciation import get_disposal_account_and_cost_center
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class SalesInvoiceItem(Document):
|
||||
class SalesInvoiceItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class AssetCapitalizationStockItem(Document):
|
||||
class AssetCapitalizationStockItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class AssetRepairConsumedItem(Document):
|
||||
class AssetRepairConsumedItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PurchaseReceiptItemSupplied(Document):
|
||||
class PurchaseReceiptItemSupplied(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -596,13 +596,15 @@ def get_batch_no(doctype: str, txt: str, searchfield: str, start: int, page_len:
|
||||
if filters.get("is_inward"):
|
||||
filtered_batches.extend(get_empty_batches(filters, start, page_len, filtered_batches, txt))
|
||||
|
||||
return filtered_batches
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
labels = SerialBatchIdentity("Batch").labels([row[0] for row in filtered_batches])
|
||||
return [(row[0], labels.get(row[0], row[0]), *row[1:]) for row in filtered_batches]
|
||||
|
||||
|
||||
def get_empty_batches(filters, start, page_len, filtered_batches=None, txt=None):
|
||||
query_filter = {"item": filters.get("item_code"), "disabled": 0}
|
||||
if txt:
|
||||
query_filter["name"] = ("like", f"%{txt}%")
|
||||
or_filters = {"batch_id": ("like", f"%{txt}%"), "name": txt} if txt else None
|
||||
|
||||
exclude_batches = [batch[0] for batch in filtered_batches] if filtered_batches else []
|
||||
if exclude_batches:
|
||||
@@ -612,6 +614,7 @@ def get_empty_batches(filters, start, page_len, filtered_batches=None, txt=None)
|
||||
"Batch",
|
||||
fields=["name", "batch_qty"],
|
||||
filters=query_filter,
|
||||
or_filters=or_filters,
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=1,
|
||||
@@ -687,7 +690,7 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p
|
||||
query = query.select(batch_table[field])
|
||||
|
||||
if txt:
|
||||
txt_condition = batch_table.name.like(f"%{txt}%")
|
||||
txt_condition = batch_table.batch_id.like(f"%{txt}%")
|
||||
for field in [*searchfields, "name"]:
|
||||
txt_condition |= batch_table[field].like(f"%{txt}%")
|
||||
|
||||
@@ -753,7 +756,7 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0
|
||||
bundle_query = bundle_query.select(batch_table[field])
|
||||
|
||||
if txt:
|
||||
txt_condition = batch_table.name.like(f"%{txt}%")
|
||||
txt_condition = batch_table.batch_id.like(f"%{txt}%")
|
||||
for field in [*searchfields, "name"]:
|
||||
txt_condition |= batch_table[field].like(f"%{txt}%")
|
||||
|
||||
@@ -1018,11 +1021,11 @@ def get_batch_numbers(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
batch = frappe.qb.DocType("Batch")
|
||||
query = (
|
||||
frappe.qb.from_(batch)
|
||||
.select(batch.batch_id)
|
||||
.select(batch.name, batch.batch_id, batch.item)
|
||||
.where(
|
||||
(batch.disabled == 0)
|
||||
& (batch.expiry_date.isnull() | (batch.expiry_date >= today()))
|
||||
& batch.name.like(f"%{txt}%")
|
||||
& batch.batch_id.like(f"%{txt}%")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
|
||||
)
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_nos_from_bundle
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.stock.utils import get_incoming_rate
|
||||
|
||||
|
||||
@@ -433,9 +434,10 @@ class SubcontractingController(StockController):
|
||||
consumed_bundles = voucher_bundle_data.get(bundle_key, frappe._dict())
|
||||
|
||||
if consumed_bundles.serial_nos:
|
||||
self.available_materials[key]["serial_no"] = list(
|
||||
set(self.available_materials[key]["serial_no"]) - set(consumed_bundles.serial_nos)
|
||||
)
|
||||
consumed_serials = set(consumed_bundles.serial_nos)
|
||||
self.available_materials[key]["serial_no"] = [
|
||||
sn for sn in self.available_materials[key]["serial_no"] if sn not in consumed_serials
|
||||
]
|
||||
|
||||
if consumed_bundles.batch_nos:
|
||||
for batch_no, qty in consumed_bundles.batch_nos.items():
|
||||
@@ -449,9 +451,10 @@ class SubcontractingController(StockController):
|
||||
from erpnext.deprecation_dumpster import deprecation_warning
|
||||
|
||||
deprecation_warning("unknown", "v16", "No instructions.")
|
||||
self.available_materials[key]["serial_no"] = list(
|
||||
set(self.available_materials[key]["serial_no"]) - set(get_serial_nos(row.serial_no))
|
||||
)
|
||||
consumed_serials = set(get_serial_nos(row.serial_no))
|
||||
self.available_materials[key]["serial_no"] = [
|
||||
sn for sn in self.available_materials[key]["serial_no"] if sn not in consumed_serials
|
||||
]
|
||||
|
||||
# Will be deprecated in v16
|
||||
if row.batch_no and not consumed_bundles.batch_nos:
|
||||
@@ -531,6 +534,12 @@ class SubcontractingController(StockController):
|
||||
|
||||
self.__set_alternative_item_details(row)
|
||||
|
||||
serial_numbers = SerialBatchIdentity("Serial No").labels(
|
||||
[sn for details in self.available_materials.values() for sn in details.serial_no]
|
||||
)
|
||||
for details in self.available_materials.values():
|
||||
details.serial_no.sort(key=lambda sn: serial_numbers.get(sn) or sn)
|
||||
|
||||
self.__transferred_items = copy.deepcopy(self.available_materials)
|
||||
self.__update_consumed_materials("Subcontracting Receipt")
|
||||
|
||||
@@ -682,7 +691,7 @@ class SubcontractingController(StockController):
|
||||
return available_batches
|
||||
|
||||
def __get_serial_nos_for_bundle(self, qty, key):
|
||||
available_sns = sorted(self.available_materials[key]["serial_no"])[0 : cint(qty)]
|
||||
available_sns = self.available_materials[key]["serial_no"][0 : cint(qty)]
|
||||
serial_nos = []
|
||||
|
||||
for serial_no in available_sns:
|
||||
|
||||
@@ -995,9 +995,9 @@ class TestSubcontractingController(ERPNextTestSuite):
|
||||
if value.get(field):
|
||||
data = value.get(field)
|
||||
if field == "serial_no":
|
||||
data = sorted(data)
|
||||
|
||||
self.assertEqual(data, transferred_detais.get(field))
|
||||
self.assertCountEqual(data, transferred_detais.get(field))
|
||||
else:
|
||||
self.assertEqual(data, transferred_detais.get(field))
|
||||
|
||||
scr2 = make_subcontracting_receipt(sco.name)
|
||||
scr2.save()
|
||||
@@ -1010,9 +1010,9 @@ class TestSubcontractingController(ERPNextTestSuite):
|
||||
if value.get(field):
|
||||
data = value.get(field)
|
||||
if field == "serial_no":
|
||||
data = sorted(data)
|
||||
|
||||
self.assertEqual(data, transferred_detais.get(field))
|
||||
self.assertCountEqual(data, transferred_detais.get(field))
|
||||
else:
|
||||
self.assertEqual(data, transferred_detais.get(field))
|
||||
|
||||
def test_subcontracting_with_same_components_different_fg_with_serial_batch_fields(self):
|
||||
"""
|
||||
@@ -1338,7 +1338,7 @@ def make_stock_transfer_entry(**args):
|
||||
batches = defaultdict(float)
|
||||
if item_details and item_details.serial_no:
|
||||
serial_nos = item_details.serial_no[0 : cint(row.qty)]
|
||||
item_details.serial_no = list(set(item_details.serial_no) - set(serial_nos))
|
||||
item_details.serial_no = item_details.serial_no[cint(row.qty) :]
|
||||
|
||||
if item_details and item_details.batch_no:
|
||||
for batch_no, batch_qty in item_details.batch_no.items():
|
||||
|
||||
@@ -72,7 +72,10 @@ doctype_list_js = {
|
||||
|
||||
page_js = {"print": "public/js/print.js"}
|
||||
|
||||
extend_doctype_class = {"Address": "erpnext.accounts.custom.address.ERPNextAddress"}
|
||||
extend_doctype_class = {
|
||||
"Address": "erpnext.accounts.custom.address.ERPNextAddress",
|
||||
"Data Import": "erpnext.stock.serial_batch_import.SerialBatchDataImport",
|
||||
}
|
||||
|
||||
override_whitelisted_methods = {"frappe.www.contact.send_message": "erpnext.templates.utils.send_message"}
|
||||
|
||||
@@ -384,6 +387,7 @@ pre_submit_validation_doctypes = [
|
||||
|
||||
doc_events = {
|
||||
"*": {
|
||||
"before_print": "erpnext.stock.serial_batch_display.set_serial_number_labels",
|
||||
"validate": [
|
||||
"erpnext.support.doctype.service_level_agreement.service_level_agreement.apply",
|
||||
"erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job",
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class MaintenanceScheduleDetail(Document):
|
||||
class MaintenanceScheduleDetail(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class MaintenanceScheduleItem(Document):
|
||||
class MaintenanceScheduleItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -1860,7 +1860,7 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(len(entries), 5)
|
||||
for entry in entries:
|
||||
self.assertEqual(flt(entry.qty), 10.0)
|
||||
self.assertTrue(entry.batch_no.startswith("BS-ROD-PC-"))
|
||||
self.assertTrue(frappe.db.get_value("Batch", entry.batch_no, "batch_id").startswith("BS-ROD-PC-"))
|
||||
self.assertEqual(frappe.db.get_value("Batch", entry.batch_no, "parent_batch"), parent_batch)
|
||||
|
||||
manufacture_entry.reload()
|
||||
|
||||
@@ -36,6 +36,7 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.doctype.stock_entry import test_stock_entry
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import OperationsNotCompleteError
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.stock.utils import get_bin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -1971,6 +1972,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 4 / 10, places=6)
|
||||
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 4 / 10, places=6)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_multiple_items": 0})
|
||||
def test_allocation_collapses_groups_when_multiple_items_disallowed(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
@@ -2163,6 +2165,8 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
transferred_ste_doc.items[0].serial_no = "\n".join(serial_nos_list)
|
||||
transferred_ste_doc.items[0].serial_and_batch_bundle = None
|
||||
transferred_ste_doc.items[0].use_serial_batch_fields = 1
|
||||
transferred_ste_doc.submit()
|
||||
|
||||
# First Manufacture stock entry
|
||||
@@ -3770,8 +3774,12 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
|
||||
# Pre-generate two sets of FG serial numbers
|
||||
series = frappe.db.get_value("Item", fg_item, "serial_no_series")
|
||||
fg_serials_1 = [make_autoname(series) for _ in range(3)]
|
||||
fg_serials_2 = [make_autoname(series) for _ in range(3)]
|
||||
fg_serials_1 = SerialBatchIdentity("Serial No").resolve(
|
||||
fg_item, [make_autoname(series) for _ in range(3)], create=True
|
||||
)
|
||||
fg_serials_2 = SerialBatchIdentity("Serial No").resolve(
|
||||
fg_item, [make_autoname(series) for _ in range(3)], create=True
|
||||
)
|
||||
|
||||
# Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
|
||||
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
|
||||
@@ -830,58 +830,30 @@ class WorkOrder(Document):
|
||||
|
||||
serial_nos = []
|
||||
if item_details.serial_no_series:
|
||||
serial_nos = get_available_serial_nos(item_details.serial_no_series, self.qty)
|
||||
serial_nos = get_available_serial_nos(
|
||||
item_details.serial_no_series, self.qty, self.production_item
|
||||
)
|
||||
|
||||
if not serial_nos:
|
||||
return
|
||||
|
||||
fields = [
|
||||
"name",
|
||||
"serial_no",
|
||||
"creation",
|
||||
"modified",
|
||||
"owner",
|
||||
"modified_by",
|
||||
"company",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
"status",
|
||||
"work_order",
|
||||
"batch_no",
|
||||
]
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
serial_nos_details = []
|
||||
index = 0
|
||||
for serial_no in serial_nos:
|
||||
index += 1
|
||||
batch_no = None
|
||||
if batches and self.batch_size:
|
||||
batch_no = batches[0]
|
||||
groups = {}
|
||||
for index, number in enumerate(serial_nos, 1):
|
||||
batch_no = batches[0] if batches and self.batch_size else None
|
||||
groups.setdefault(batch_no, []).append(number)
|
||||
if batch_no and index % self.batch_size == 0:
|
||||
batches.pop(0)
|
||||
|
||||
if index % self.batch_size == 0:
|
||||
batches.remove(batch_no)
|
||||
|
||||
serial_nos_details.append(
|
||||
(
|
||||
serial_no,
|
||||
serial_no,
|
||||
now(),
|
||||
now(),
|
||||
frappe.session.user,
|
||||
frappe.session.user,
|
||||
self.company,
|
||||
self.production_item,
|
||||
item_details.item_name,
|
||||
item_details.description,
|
||||
"Inactive",
|
||||
self.name,
|
||||
batch_no,
|
||||
)
|
||||
for batch_no, numbers in groups.items():
|
||||
SerialBatchIdentity("Serial No").resolve(
|
||||
self.production_item,
|
||||
numbers,
|
||||
create=True,
|
||||
defaults={"company": self.company, "work_order": self.name, "batch_no": batch_no},
|
||||
)
|
||||
|
||||
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
|
||||
|
||||
def validate_cancel(self):
|
||||
if self.status == "Stopped":
|
||||
frappe.throw(_("Stopped Work Order cannot be cancelled, Unstop it first to cancel"))
|
||||
|
||||
@@ -262,6 +262,7 @@ erpnext.patches.v15_0.rename_subcontracting_fields
|
||||
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
|
||||
erpnext.patches.v16_0.convert_commission_rate_to_percent
|
||||
erpnext.patches.v16_0.convert_hide_currency_symbol_to_check
|
||||
erpnext.patches.v17_0.separate_serial_batch_identity
|
||||
|
||||
[post_model_sync]
|
||||
erpnext.patches.v15_0.rename_gross_purchase_amount_to_net_purchase_amount
|
||||
|
||||
0
erpnext/patches/v17_0/__init__.py
Normal file
0
erpnext/patches/v17_0/__init__.py
Normal file
20
erpnext/patches/v17_0/separate_serial_batch_identity.py
Normal file
20
erpnext/patches/v17_0/separate_serial_batch_identity.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
|
||||
def execute():
|
||||
checked = []
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
identity = SerialBatchIdentity(doctype)
|
||||
if not identity.has_constraint():
|
||||
identity.validate_existing_numbers()
|
||||
checked.append(doctype)
|
||||
|
||||
previous = frappe.flags.serial_batch_preflight
|
||||
try:
|
||||
frappe.flags.serial_batch_preflight = checked
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
frappe.reload_doc("stock", "doctype", frappe.scrub(doctype), force=True)
|
||||
finally:
|
||||
frappe.flags.serial_batch_preflight = previous
|
||||
@@ -6,6 +6,8 @@ import "./utils/party";
|
||||
import "./utils/draft_link_guard";
|
||||
import "./controllers/stock_controller";
|
||||
import "./utils/serial_no_batch_selector";
|
||||
import "./utils/serial_batch_input";
|
||||
import "./utils/serial_batch_display";
|
||||
import "./utils/serial_batch_inline_editor";
|
||||
import "./payment/payments";
|
||||
import "./templates/visual_plant_floor_template.html";
|
||||
|
||||
@@ -55,8 +55,16 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scan_api_call(input, (r) => {
|
||||
const data = r && r.message;
|
||||
this.scan_api_call(input, async (r) => {
|
||||
let data = r && r.message;
|
||||
if (data?.candidates) {
|
||||
data = await this.select_scan_match(data.candidates);
|
||||
if (!data) {
|
||||
this.clean_up();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!data ||
|
||||
Object.keys(data).length === 0 ||
|
||||
@@ -95,29 +103,98 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
});
|
||||
}
|
||||
|
||||
scan_api_call(input, callback) {
|
||||
select_scan_match(candidates) {
|
||||
const item_codes = [...new Set(candidates.map((candidate) => candidate.item_code))];
|
||||
if (item_codes.length <= 1) {
|
||||
return Promise.resolve(this.get_scan_match(candidates, item_codes[0]));
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let selected = false;
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Select Item"),
|
||||
size: "small",
|
||||
fields: [
|
||||
{
|
||||
fieldname: "item_code",
|
||||
label: __("Item"),
|
||||
fieldtype: "Link",
|
||||
options: "Item",
|
||||
only_select: 1,
|
||||
get_query: () => ({ filters: { name: ["in", item_codes] } }),
|
||||
filter_description: __("Items matching the scanned number"),
|
||||
reqd: 1,
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Select"),
|
||||
primary_action: ({ item_code }) => {
|
||||
const match = this.get_scan_match(candidates, item_code);
|
||||
if (!match) return;
|
||||
selected = true;
|
||||
dialog.hide();
|
||||
resolve(match);
|
||||
},
|
||||
onhide: () => {
|
||||
if (!selected) resolve(null);
|
||||
},
|
||||
});
|
||||
dialog.show();
|
||||
});
|
||||
}
|
||||
|
||||
get_scan_match(candidates, item_code) {
|
||||
const matches = candidates.filter((candidate) => candidate.item_code === item_code);
|
||||
// Keep the serial reference when the same item's barcode or batch number also matches.
|
||||
return (
|
||||
matches.find((match) => match.serial_no) || matches.find((match) => match.batch_no) || matches[0]
|
||||
);
|
||||
}
|
||||
|
||||
scan_api_call(input, callback, item_code) {
|
||||
frappe
|
||||
.call({
|
||||
method: this.scan_api,
|
||||
args: {
|
||||
search_value: input,
|
||||
allow_multiple: true,
|
||||
ctx: {
|
||||
item_code,
|
||||
set_warehouse: this.frm.doc.set_warehouse,
|
||||
company: this.frm.doc.company,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((r) => {
|
||||
for (const match of r.message?.candidates || [r.message || {}]) {
|
||||
if (match.serial_no && match.serial_number)
|
||||
frappe.utils.add_link_title("Serial No", match.serial_no, match.serial_number);
|
||||
if (match.batch_no && match.batch_number)
|
||||
frappe.utils.add_link_title("Batch", match.batch_no, match.batch_number);
|
||||
}
|
||||
callback(r);
|
||||
});
|
||||
}
|
||||
|
||||
update_table(data) {
|
||||
if (data.has_serial_no && data.batch_no && !data.serial_no) {
|
||||
frappe.msgprint(__("Please scan a serial number for Item {0}", [data.item_code]));
|
||||
return Promise.reject();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
|
||||
frappe.flags.trigger_from_barcode_scanner = true;
|
||||
|
||||
const { item_code, barcode, batch_no, serial_no, uom, default_warehouse } = data;
|
||||
if (
|
||||
serial_no &&
|
||||
(this.frm.doc[this.items_table_name] || []).some(
|
||||
(row) => row.item_code === item_code && this.is_duplicate_serial_no(row, serial_no)
|
||||
)
|
||||
) {
|
||||
this.clean_up();
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
let row = this.get_row_to_modify_on_scan(item_code, batch_no, uom, barcode, default_warehouse);
|
||||
const is_new_row = !row?.item_code;
|
||||
if (!row) {
|
||||
@@ -135,12 +212,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
this.frm.has_items = false;
|
||||
}
|
||||
|
||||
if (this.is_duplicate_serial_no(row, serial_no)) {
|
||||
this.clean_up();
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.run_serially([
|
||||
() => this.set_selector_trigger_flag(data),
|
||||
() => this.set_barcode(row, barcode),
|
||||
@@ -180,9 +251,18 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
set_item(row, item_code, barcode, batch_no, serial_no) {
|
||||
return new Promise((resolve) => {
|
||||
const increment = async (value = 1) => {
|
||||
const item_data = { item_code: item_code, use_serial_batch_fields: 1.0 };
|
||||
const existing = erpnext.serial_batch_input.is_pending(row, this.serial_no_field)
|
||||
? ""
|
||||
: row[this.serial_no_field];
|
||||
const item_data = this.get_scanned_item_values(
|
||||
row,
|
||||
item_code,
|
||||
batch_no,
|
||||
serial_no ? this.merge_serial_nos(existing, serial_no) : null
|
||||
);
|
||||
frappe.flags.trigger_from_barcode_scanner = true;
|
||||
item_data[this.qty_field] = Number(row[this.qty_field] || 0) + Number(value);
|
||||
item_data[this.qty_field] =
|
||||
Number((row.item_code && row[this.qty_field]) || 0) + Number(value);
|
||||
await frappe.model.set_value(row.doctype, row.name, item_data);
|
||||
return value;
|
||||
};
|
||||
@@ -199,6 +279,28 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
});
|
||||
}
|
||||
|
||||
get_scanned_item_values(row, item_code, batch_no, serial_no) {
|
||||
// Item selection must receive the scanned references before it can auto-pick stock.
|
||||
const values = { item_code, use_serial_batch_fields: 1 };
|
||||
if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) {
|
||||
if (erpnext.serial_batch_input.is_pending(row, this.serial_no_field)) {
|
||||
const numbers = serial_no
|
||||
.split("\n")
|
||||
.map((id) => frappe.utils.get_link_title("Serial No", id) || id)
|
||||
.join("\n");
|
||||
values[this.serial_no_field] = this.merge_serial_nos(row[this.serial_no_field], numbers);
|
||||
erpnext.serial_batch_input.mark(row, this.serial_no_field, values[this.serial_no_field]);
|
||||
} else {
|
||||
values[this.serial_no_field] = serial_no;
|
||||
}
|
||||
}
|
||||
if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) {
|
||||
values[this.batch_no_field] = batch_no;
|
||||
erpnext.serial_batch_input.clear(row, this.batch_no_field);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
prepare_item_for_scan(row, item_code, barcode, batch_no, serial_no) {
|
||||
var me = this;
|
||||
this.dialog = new frappe.ui.Dialog({
|
||||
@@ -206,19 +308,23 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
fields: me.get_fields_for_dialog(row, item_code, barcode, batch_no, serial_no),
|
||||
});
|
||||
|
||||
this.dialog.set_primary_action(__("Update"), () => {
|
||||
const item_data = { item_code: item_code };
|
||||
this.dialog.set_primary_action(__("Update"), async () => {
|
||||
const item_data = this.get_scanned_item_values(
|
||||
row,
|
||||
item_code,
|
||||
this.dialog.get_value("batch_no"),
|
||||
this.dialog.get_value("serial_no")
|
||||
);
|
||||
item_data[this.qty_field] = this.dialog.get_value("scanned_qty");
|
||||
item_data["has_item_scanned"] = 1;
|
||||
|
||||
this.remaining_qty =
|
||||
flt(this.dialog.get_value("qty")) - flt(this.dialog.get_value("scanned_qty"));
|
||||
frappe.model.set_value(row.doctype, row.name, item_data);
|
||||
await frappe.model.set_value(row.doctype, row.name, item_data);
|
||||
|
||||
frappe.run_serially([
|
||||
await frappe.run_serially([
|
||||
() => this.set_batch_no(row, this.dialog.get_value("batch_no")),
|
||||
() => this.set_barcode(row, this.dialog.get_value("barcode")),
|
||||
() => this.set_serial_no(row, this.dialog.get_value("serial_no")),
|
||||
() => this.add_child_for_remaining_qty(row),
|
||||
() => this.clean_up(),
|
||||
]);
|
||||
@@ -245,11 +351,15 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
}
|
||||
|
||||
if (e.target.value) {
|
||||
this.scan_api_call(e.target.value, (r) => {
|
||||
if (r.message) {
|
||||
this.update_dialog_values(item_code, r);
|
||||
}
|
||||
});
|
||||
this.scan_api_call(
|
||||
e.target.value,
|
||||
async (r) => {
|
||||
if (r.message?.candidates)
|
||||
r.message = await this.select_scan_match(r.message.candidates);
|
||||
if (r.message) this.update_dialog_values(item_code, r);
|
||||
},
|
||||
item_code
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -282,7 +392,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
fields.push({
|
||||
fieldtype: "Link",
|
||||
fieldname: "batch_no",
|
||||
options: "Batch No",
|
||||
options: "Batch",
|
||||
label: __("Batch No"),
|
||||
default: batch_no,
|
||||
read_only: 1,
|
||||
@@ -297,6 +407,17 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
label: __("Serial Nos"),
|
||||
default: serial_no,
|
||||
read_only: 1,
|
||||
hidden: 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (serial_no) {
|
||||
fields.push({
|
||||
fieldtype: "Small Text",
|
||||
fieldname: "serial_numbers",
|
||||
label: __("Serial Nos"),
|
||||
default: frappe.utils.get_link_title("Serial No", serial_no) || serial_no,
|
||||
read_only: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -316,7 +437,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
}
|
||||
|
||||
update_dialog_values(scanned_item, r) {
|
||||
const { item_code, barcode, batch_no, serial_no } = r.message;
|
||||
const { item_code, barcode, batch_no, serial_no, serial_number } = r.message;
|
||||
|
||||
this.dialog.set_value("barcode_scanner", "");
|
||||
if (
|
||||
@@ -331,6 +452,10 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
this.validate_duplicate_serial_no(serial_no);
|
||||
let serial_nos = this.dialog.get_value("serial_no") + "\n" + serial_no;
|
||||
this.dialog.set_value("serial_no", serial_nos);
|
||||
this.dialog.set_value(
|
||||
"serial_numbers",
|
||||
this.dialog.get_value("serial_numbers") + "\n" + (serial_number || serial_no)
|
||||
);
|
||||
}
|
||||
|
||||
let qty = flt(this.dialog.get_value("scanned_qty")) + 1.0;
|
||||
@@ -382,18 +507,24 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
|
||||
async set_serial_no(row, serial_no) {
|
||||
if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) {
|
||||
const existing_serial_nos = row[this.serial_no_field];
|
||||
let new_serial_nos = "";
|
||||
|
||||
if (!!existing_serial_nos) {
|
||||
new_serial_nos = existing_serial_nos + "\n" + serial_no;
|
||||
} else {
|
||||
new_serial_nos = serial_no;
|
||||
if (erpnext.serial_batch_input.is_pending(row, this.serial_no_field)) {
|
||||
const number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
|
||||
const merged = this.merge_serial_nos(row[this.serial_no_field], number);
|
||||
erpnext.serial_batch_input.mark(row, this.serial_no_field, merged);
|
||||
await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, merged);
|
||||
return;
|
||||
}
|
||||
const new_serial_nos = this.merge_serial_nos(row[this.serial_no_field], serial_no);
|
||||
await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, new_serial_nos);
|
||||
}
|
||||
}
|
||||
|
||||
merge_serial_nos(existing, added) {
|
||||
return [...new Set(`${existing || ""}\n${added || ""}`.split("\n").map((id) => id.trim()))]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async set_barcode_uom(row, uom) {
|
||||
// e.g. Pick List: picked_qty is always tracked in stock UOM, so an incidental
|
||||
// barcode uom must not overwrite the row's own uom.
|
||||
@@ -404,6 +535,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
}
|
||||
|
||||
async set_batch_no(row, batch_no) {
|
||||
erpnext.serial_batch_input.clear(row, this.batch_no_field);
|
||||
if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) {
|
||||
await frappe.model.set_value(row.doctype, row.name, this.batch_no_field, batch_no);
|
||||
}
|
||||
@@ -437,10 +569,18 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
}
|
||||
|
||||
is_duplicate_serial_no(row, serial_no) {
|
||||
const is_duplicate = row[this.serial_no_field]?.includes(serial_no);
|
||||
const physical_number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
|
||||
const pending_duplicate =
|
||||
erpnext.serial_batch_input.is_pending(row, this.serial_no_field) &&
|
||||
row[this.serial_no_field]
|
||||
?.split("\n")
|
||||
.some((number) => number.toUpperCase() === physical_number?.toUpperCase());
|
||||
const is_duplicate =
|
||||
serial_no && (pending_duplicate || row[this.serial_no_field]?.split("\n").includes(serial_no));
|
||||
|
||||
if (is_duplicate) {
|
||||
this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange");
|
||||
const number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
|
||||
this.show_alert(__("Serial No {0} is already added", [number]), "orange");
|
||||
}
|
||||
return is_duplicate;
|
||||
}
|
||||
@@ -461,7 +601,12 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
|
||||
const matching_row = (row) => {
|
||||
const item_match = row.item_code == item_code;
|
||||
const batch_match = !row[this.batch_no_field] || row[this.batch_no_field] == batch_no;
|
||||
const batch_match =
|
||||
!row[this.batch_no_field] ||
|
||||
(erpnext.serial_batch_input.is_pending(row, this.batch_no_field)
|
||||
? row[this.batch_no_field].toUpperCase() ===
|
||||
frappe.utils.get_link_title("Batch", batch_no)?.toUpperCase()
|
||||
: row[this.batch_no_field] === batch_no);
|
||||
const uom_match = !uom || this.max_qty_field || row[this.uom_field] == uom;
|
||||
const has_demand_qty = this.demand_ref_fields.some((fieldname) => row[fieldname]);
|
||||
const qty_in_limit = !has_demand_qty || flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
|
||||
|
||||
19
erpnext/public/js/utils/serial_batch_display.js
Normal file
19
erpnext/public/js/utils/serial_batch_display.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Reports export physical numbers and retain a separate ID field for each link.
|
||||
frappe.form.formatters.SerialBatchNumber = (value, df, options, doc) => {
|
||||
if (!value) return "";
|
||||
const labels = String(value).split("\n");
|
||||
const ids = String(doc?.[df.reference_field] || "").split("\n");
|
||||
return labels
|
||||
.map((label, index) => {
|
||||
if (
|
||||
!ids[index] ||
|
||||
options?.for_print ||
|
||||
options?.only_value ||
|
||||
!frappe.model.can_read(df.options)
|
||||
) {
|
||||
return frappe.utils.escape_html(label);
|
||||
}
|
||||
return frappe.form.formatters.Link(ids[index], df, { ...options, label }, doc);
|
||||
})
|
||||
.join("<br>");
|
||||
};
|
||||
@@ -469,19 +469,32 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
$td.data("editing", 1);
|
||||
|
||||
let name = $td.data("name");
|
||||
let current = $td.text().trim();
|
||||
let pending_index = $td.data("pending-index");
|
||||
let entry =
|
||||
pending_index != null
|
||||
? this.pending.new_entries[pending_index]
|
||||
: this.last_entries.find((row) => row.name === name);
|
||||
let number_field = opts.field === "serial_no" ? "serial_number" : "batch_number";
|
||||
let current =
|
||||
(pending_index == null && this.pending.updates[name]?.[opts.field]) || entry?.[opts.field] || "";
|
||||
$td.empty().addClass("sbie-input-cell").css("cursor", "default");
|
||||
this.wrapper.find(".sbie-table").css("overflow", "visible");
|
||||
|
||||
let control = this.make_row_link_control($td, {
|
||||
options: opts.options,
|
||||
fieldname: "sbie_edit_link",
|
||||
placeholder: opts.placeholder,
|
||||
placeholder: (!current && entry?.[number_field]) || opts.placeholder,
|
||||
get_query: opts.get_query,
|
||||
onchange: () => {
|
||||
let value = control.get_value();
|
||||
if (value && value !== current) {
|
||||
this.update_entry(name, { [opts.field]: value });
|
||||
if (pending_index != null) {
|
||||
entry[opts.field] = value;
|
||||
delete entry[number_field];
|
||||
this.frm.dirty();
|
||||
} else {
|
||||
this.update_entry(name, { [opts.field]: value });
|
||||
}
|
||||
this.refresh_view();
|
||||
}
|
||||
},
|
||||
@@ -636,7 +649,9 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
for (const row of rows) {
|
||||
p.new_entries.push({
|
||||
serial_no: row.serial_no || "",
|
||||
serial_number: row.serial_number,
|
||||
batch_no: row.batch_no || "",
|
||||
batch_number: row.batch_number,
|
||||
qty: Math.abs(flt(row.qty)) || 1,
|
||||
});
|
||||
}
|
||||
@@ -692,22 +707,37 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
get_entry_number(entry, field) {
|
||||
let update = this.pending.updates[entry.name] || {};
|
||||
let name = update[field] || entry[field];
|
||||
let is_serial = field === "serial_no";
|
||||
return (
|
||||
(!update[field] && entry[is_serial ? "serial_number" : "batch_number"]) ||
|
||||
frappe.utils.get_link_title(is_serial ? "Serial No" : "Batch", name) ||
|
||||
name ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
get_active_server_row(field, value) {
|
||||
let p = this.pending;
|
||||
if (p.delete_all) return null;
|
||||
|
||||
return this.last_entries.find((d) => d[field] === value && !p.deleted.some((x) => x.name === d.name));
|
||||
return this.last_entries.find(
|
||||
(d) => this.get_entry_number(d, field) === value && !p.deleted.some((x) => x.name === d.name)
|
||||
);
|
||||
}
|
||||
|
||||
get_known_identifiers() {
|
||||
let p = this.pending;
|
||||
let known = new Set(p.new_entries.map((d) => d.serial_no || d.batch_no));
|
||||
let field = cint(this.item.has_serial_no) ? "serial_no" : "batch_no";
|
||||
let known = new Set(p.new_entries.map((d) => this.get_entry_number(d, field)));
|
||||
|
||||
if (!p.delete_all) {
|
||||
let deleted = new Set(p.deleted.map((d) => d.name));
|
||||
for (const d of this.last_entries) {
|
||||
if (!deleted.has(d.name)) {
|
||||
known.add(d.serial_no || d.batch_no);
|
||||
known.add(this.get_entry_number(d, field));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -727,9 +757,9 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
return false;
|
||||
}
|
||||
|
||||
p.new_entries.push({ serial_no: value, batch_no: "", qty: 1 });
|
||||
p.new_entries.push({ serial_number: value, qty: 1 });
|
||||
} else {
|
||||
let existing = p.new_entries.find((d) => d.batch_no === value);
|
||||
let existing = p.new_entries.find((d) => this.get_entry_number(d, "batch_no") === value);
|
||||
let server_row = this.get_active_server_row("batch_no", value);
|
||||
if (existing) {
|
||||
existing.qty = flt(existing.qty) + 1;
|
||||
@@ -738,7 +768,7 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
let current = update && update.qty != null ? flt(update.qty) : Math.abs(flt(server_row.qty));
|
||||
this.update_entry(server_row.name, { qty: current + 1 });
|
||||
} else {
|
||||
p.new_entries.push({ serial_no: "", batch_no: value, qty: 1 });
|
||||
p.new_entries.push({ batch_number: value, qty: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,7 +818,7 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
let added = 0;
|
||||
for (const serial_no of serial_nos) {
|
||||
if (known.has(serial_no)) continue;
|
||||
p.new_entries.push({ serial_no: serial_no, batch_no: "", qty: 1 });
|
||||
p.new_entries.push({ serial_number: serial_no, qty: 1 });
|
||||
added++;
|
||||
}
|
||||
|
||||
@@ -935,8 +965,8 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
.map((d, i) => {
|
||||
let update = p.updates[d.name] || {};
|
||||
let qty = update.qty != null ? flt(update.qty) : Math.abs(flt(d.qty));
|
||||
let batch_no = this.esc(update.batch_no || d.batch_no || "");
|
||||
let serial_no = this.esc(update.serial_no || d.serial_no || "");
|
||||
let batch_no = this.esc(this.get_entry_number(d, "batch_no"));
|
||||
let serial_no = this.esc(this.get_entry_number(d, "serial_no"));
|
||||
let name = this.esc(d.name);
|
||||
|
||||
return `<tr data-name="${name}">
|
||||
@@ -957,8 +987,12 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
)}" style="cursor: pointer;">${batch_no}</td>`
|
||||
: ""
|
||||
}
|
||||
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
|
||||
!d.serial_no && show_batch ? this.get_qty_input(d, qty) : this.format_float(qty)
|
||||
<td class="${
|
||||
!(d.serial_no || d.serial_number) && show_batch ? "sbie-input-cell" : ""
|
||||
}" style="text-align: right;">${
|
||||
!(d.serial_no || d.serial_number) && show_batch
|
||||
? this.get_qty_input(d, qty)
|
||||
: this.format_float(qty)
|
||||
}</td>
|
||||
</tr>`;
|
||||
})
|
||||
@@ -975,10 +1009,26 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
<td style="text-align: center;">
|
||||
<input type="checkbox" class="sbie-check" data-pending-index="${index}"></td>
|
||||
<td style="text-align: center;">${base_count + index + 1}</td>
|
||||
${show_serial ? `<td>${this.esc(d.serial_no || "")}</td>` : ""}
|
||||
${show_batch ? `<td>${this.esc(d.batch_no || "")}</td>` : ""}
|
||||
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
|
||||
!d.serial_no && show_batch
|
||||
${
|
||||
show_serial
|
||||
? `<td class="sbie-serial-cell" data-pending-index="${index}" title="${__(
|
||||
"Click to change Serial No"
|
||||
)}" style="cursor: pointer;">${this.esc(
|
||||
this.get_entry_number(d, "serial_no")
|
||||
)}</td>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
show_batch
|
||||
? `<td class="sbie-batch-cell" data-pending-index="${index}" title="${__(
|
||||
"Click to change Batch No"
|
||||
)}" style="cursor: pointer;">${this.esc(this.get_entry_number(d, "batch_no"))}</td>`
|
||||
: ""
|
||||
}
|
||||
<td class="${
|
||||
!(d.serial_no || d.serial_number) && show_batch ? "sbie-input-cell" : ""
|
||||
}" style="text-align: right;">${
|
||||
!(d.serial_no || d.serial_number) && show_batch
|
||||
? this.get_pending_qty_input(d, index)
|
||||
: this.format_float(d.qty)
|
||||
}</td>
|
||||
|
||||
282
erpnext/public/js/utils/serial_batch_input.js
Normal file
282
erpnext/public/js/utils/serial_batch_input.js
Normal file
@@ -0,0 +1,282 @@
|
||||
// Physical input remains pending until the transaction is saved.
|
||||
const registered_forms = new Set();
|
||||
const pending_values = new WeakMap();
|
||||
const serial_list_fields = new Set(["serial_no", "rejected_serial_no", "current_serial_no"]);
|
||||
|
||||
const with_serial_numbers = (BaseControl) =>
|
||||
class extends BaseControl {
|
||||
number_context() {
|
||||
return this.serial_batch_context || { frm: this.frm, row: this.doc };
|
||||
}
|
||||
|
||||
is_serial_list() {
|
||||
const { frm, row } = this.number_context();
|
||||
return (
|
||||
frm &&
|
||||
this.df.parent !== "Serial No" &&
|
||||
serial_list_fields.has(this.df.fieldname) &&
|
||||
(row?.item_code || row?.rm_item_code)
|
||||
);
|
||||
}
|
||||
|
||||
bind_change_event() {
|
||||
if (!this.frm || !serial_list_fields.has(this.df.fieldname) || this.df.parent === "Serial No")
|
||||
return super.bind_change_event();
|
||||
this.$input.on("change", (event) =>
|
||||
this.parse_validate_and_set_in_model(this.get_input_value(), event)
|
||||
);
|
||||
this.$input.on("input", () => this.number_context().frm.dirty());
|
||||
}
|
||||
|
||||
async parse_validate_and_set_in_model(value, event) {
|
||||
const revision = (this.number_revision = (this.number_revision || 0) + 1);
|
||||
if (!this.is_serial_list() || !event) {
|
||||
return super.parse_validate_and_set_in_model(value, event);
|
||||
}
|
||||
const context = this.number_context();
|
||||
if (
|
||||
context.row.parenttype &&
|
||||
frappe.meta.has_field(context.row.doctype, "serial_and_batch_bundle")
|
||||
) {
|
||||
const numbers = split_physical_numbers(value);
|
||||
await set_pending_number(context, this.df.fieldname, numbers.join("\n"));
|
||||
return;
|
||||
}
|
||||
const { frm, row } = this.number_context();
|
||||
const item_code = row.item_code || row.rm_item_code;
|
||||
const pending = (async () => {
|
||||
const numbers = (value || "")
|
||||
.split(/[,\n]/)
|
||||
.map((number) => number.trim())
|
||||
.filter(Boolean);
|
||||
const result = numbers.length
|
||||
? await frappe.xcall("erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers", {
|
||||
item_code,
|
||||
serial_numbers: numbers,
|
||||
})
|
||||
: { serial_nos: [] };
|
||||
const ids = result.serial_nos;
|
||||
if (revision !== this.number_revision || item_code !== (row.item_code || row.rm_item_code))
|
||||
return;
|
||||
ids.forEach((id, index) => frappe.utils.add_link_title("Serial No", id, numbers[index]));
|
||||
return super.parse_validate_and_set_in_model(ids.join("\n"), event);
|
||||
})();
|
||||
track_number_request(frm, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
if (revision === this.number_revision) this.set_formatted_input(this.get_model_value());
|
||||
throw error;
|
||||
} finally {
|
||||
frm.serial_number_requests.delete(pending);
|
||||
}
|
||||
}
|
||||
|
||||
serial_number_text(value) {
|
||||
const { row } = this.number_context();
|
||||
if (erpnext.serial_batch_input.is_pending(row, this.df.fieldname)) return value || "";
|
||||
return (value || "")
|
||||
.split("\n")
|
||||
.map((id) => frappe.utils.get_link_title("Serial No", id) || id)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async load_serial_titles(value) {
|
||||
if (erpnext.serial_batch_input.is_pending(this.number_context().row, this.df.fieldname)) return;
|
||||
const missing = (value || "")
|
||||
.split("\n")
|
||||
.filter((id) => id && !frappe.utils.get_link_title("Serial No", id));
|
||||
if (!missing.length) return;
|
||||
if (this.title_request_value !== value) {
|
||||
this.title_request_value = value;
|
||||
this.title_request = frappe.xcall(
|
||||
"erpnext.stock.serial_batch_identity.get_serial_batch_labels",
|
||||
{
|
||||
doctype: "Serial No",
|
||||
names: missing,
|
||||
}
|
||||
);
|
||||
}
|
||||
const labels = await this.title_request;
|
||||
Object.entries(labels).forEach(([id, label]) =>
|
||||
frappe.utils.add_link_title("Serial No", id, label)
|
||||
);
|
||||
}
|
||||
|
||||
set_formatted_input(value) {
|
||||
if (!this.is_serial_list()) return super.set_formatted_input(value);
|
||||
super.set_formatted_input(this.serial_number_text(value));
|
||||
this.load_serial_titles(value).then(() => {
|
||||
if (this.get_model_value() === value && !this.$input?.is(":focus")) {
|
||||
super.set_formatted_input(this.serial_number_text(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
set_disp_area(value) {
|
||||
if (!this.is_serial_list()) return super.set_disp_area(value);
|
||||
if (this.disp_area) $(this.disp_area).text(this.serial_number_text(value));
|
||||
this.load_serial_titles(value).then(() => {
|
||||
if (this.disp_area && this.get_model_value() === value) {
|
||||
$(this.disp_area).text(this.serial_number_text(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
frappe.ui.form.ControlSmallText = with_serial_numbers(frappe.ui.form.ControlSmallText);
|
||||
frappe.ui.form.ControlText = with_serial_numbers(frappe.ui.form.ControlText);
|
||||
frappe.ui.form.ControlLongText = with_serial_numbers(frappe.ui.form.ControlLongText);
|
||||
|
||||
frappe.ui.form.ControlLink = class extends frappe.ui.form.ControlLink {
|
||||
async parse_validate_and_set_in_model(value, event, label) {
|
||||
const revision = (this.number_revision = (this.number_revision || 0) + 1);
|
||||
const doctype = this.get_options();
|
||||
const { frm, row } = this.serial_batch_context || { frm: this.frm, row: this.doc };
|
||||
const item_code = row?.item_code || row?.rm_item_code || row?.item;
|
||||
if (
|
||||
!frm ||
|
||||
!item_code ||
|
||||
!["Serial No", "Batch"].includes(doctype) ||
|
||||
(!event && label === undefined)
|
||||
) {
|
||||
return super.parse_validate_and_set_in_model(value, event, label);
|
||||
}
|
||||
|
||||
if (
|
||||
doctype === "Batch" &&
|
||||
this.df.fieldname === "batch_no" &&
|
||||
row.parenttype &&
|
||||
frappe.meta.has_field(row.doctype, "serial_and_batch_bundle")
|
||||
) {
|
||||
await set_pending_number({ frm, row }, "batch_no", (label ?? this.get_label_value()).trim());
|
||||
return;
|
||||
}
|
||||
if (label !== undefined) erpnext.serial_batch_input.clear(row, this.df.fieldname);
|
||||
|
||||
// Autocomplete supplies the selected physical label; change/blur supplies typed text.
|
||||
const number = (label ?? this.get_label_value()).trim();
|
||||
const pending = (async () => {
|
||||
let name = "";
|
||||
if (number) {
|
||||
const serial = doctype === "Serial No";
|
||||
const result = await frappe.xcall(
|
||||
"erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers",
|
||||
{
|
||||
item_code,
|
||||
[serial ? "serial_numbers" : "batch_numbers"]: [number],
|
||||
}
|
||||
);
|
||||
name = result[serial ? "serial_nos" : "batch_nos"][0];
|
||||
}
|
||||
if (
|
||||
revision !== this.number_revision ||
|
||||
item_code !== (row?.item_code || row?.rm_item_code || row?.item)
|
||||
)
|
||||
return;
|
||||
return super.parse_validate_and_set_in_model(name, event, number);
|
||||
})();
|
||||
track_number_request(frm, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
if (revision === this.number_revision) this.set_formatted_input(this.get_model_value());
|
||||
throw error;
|
||||
} finally {
|
||||
frm.serial_number_requests.delete(pending);
|
||||
}
|
||||
}
|
||||
set_formatted_input(value) {
|
||||
super.set_formatted_input(value);
|
||||
const { row } = this.serial_batch_context || { row: this.doc };
|
||||
if (this.df.fieldname === "batch_no" && erpnext.serial_batch_input.is_pending(row, "batch_no")) {
|
||||
this.$input?.val(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function split_physical_numbers(value) {
|
||||
return (value || "")
|
||||
.split(/[,\n]/)
|
||||
.map((number) => number.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function set_pending_number({ frm, row }, field, value) {
|
||||
erpnext.serial_batch_input.mark(row, field, value);
|
||||
row[field] = value;
|
||||
frm.dirty();
|
||||
frm.refresh_field(row.parentfield || field);
|
||||
const values = {};
|
||||
if (frappe.meta.has_field(row.doctype, "use_serial_batch_fields")) values.use_serial_batch_fields = 1;
|
||||
if (frappe.meta.has_field(row.doctype, "serial_and_batch_bundle")) values.serial_and_batch_bundle = "";
|
||||
const pending = (async () => {
|
||||
await frappe.model.set_value(row.doctype, row.name, values);
|
||||
const numbers = split_physical_numbers(value);
|
||||
if (field === "serial_no" && numbers.length && !frm.doc.is_return && row.serial_no === value) {
|
||||
await frappe.model.set_value(
|
||||
row.doctype,
|
||||
row.name,
|
||||
"qty",
|
||||
numbers.length / (row.conversion_factor || 1)
|
||||
);
|
||||
}
|
||||
})();
|
||||
track_number_request(frm, pending);
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
frm.serial_number_requests.delete(pending);
|
||||
}
|
||||
}
|
||||
|
||||
function track_number_request(frm, pending) {
|
||||
if (!registered_forms.has(frm.doctype)) {
|
||||
registered_forms.add(frm.doctype);
|
||||
const wait = async (form) => {
|
||||
await Promise.all([...(form.serial_number_requests || [])]);
|
||||
for (const row of frappe.model.get_all_docs(form.doc)) {
|
||||
for (const field of [...(row.__serial_batch_input || [])]) {
|
||||
erpnext.serial_batch_input.is_pending(row, field);
|
||||
}
|
||||
}
|
||||
};
|
||||
frappe.ui.form.on(frm.doctype, {
|
||||
validate: wait,
|
||||
before_save: wait,
|
||||
after_save(form) {
|
||||
for (const row of frappe.model.get_all_docs(form.doc)) {
|
||||
delete row.__serial_batch_input;
|
||||
pending_values.delete(row);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
frm.serial_number_requests ||= new Set();
|
||||
frm.serial_number_requests.add(pending);
|
||||
}
|
||||
|
||||
erpnext.serial_batch_input = {
|
||||
mark(row, field, value) {
|
||||
row.__serial_batch_input = [...new Set([...(row.__serial_batch_input || []), field])];
|
||||
const inputs = pending_values.get(row) || {};
|
||||
inputs[field] = value;
|
||||
pending_values.set(row, inputs);
|
||||
},
|
||||
is_pending(row, field) {
|
||||
if (!row?.__serial_batch_input?.includes(field)) return false;
|
||||
const inputs = pending_values.get(row);
|
||||
if (inputs && field in inputs && inputs[field] !== row[field]) {
|
||||
this.clear(row, field);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
clear(row, field) {
|
||||
if (!row?.__serial_batch_input) return;
|
||||
row.__serial_batch_input = row.__serial_batch_input.filter((name) => name !== field);
|
||||
if (!row.__serial_batch_input.length) delete row.__serial_batch_input;
|
||||
const inputs = pending_values.get(row);
|
||||
if (inputs) delete inputs[field];
|
||||
},
|
||||
};
|
||||
@@ -54,26 +54,19 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
|
||||
qty = Math.abs(qty);
|
||||
if (qty > 0) {
|
||||
this.dialog.set_value("qty", qty).then(() => {
|
||||
this.dialog.set_value("qty", qty).then(async () => {
|
||||
if (this.item.serial_no && !this.item.serial_and_batch_bundle) {
|
||||
let serial_nos = this.item.serial_no.split("\n");
|
||||
if (serial_nos.length > 1) {
|
||||
serial_nos.forEach((serial_no) => {
|
||||
this.dialog.fields_dict.entries.df.data.push({
|
||||
serial_no: serial_no,
|
||||
batch_no: this.item.batch_no,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.dialog.set_value("scan_serial_no", this.item.serial_no);
|
||||
}
|
||||
await this.set_data(
|
||||
this.item.serial_no
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((serial_no) => ({ serial_no, batch_no: this.item.batch_no, qty: 1 }))
|
||||
);
|
||||
frappe.model.set_value(this.item.doctype, this.item.name, "serial_no", "");
|
||||
} else if (this.item.batch_no && !this.item.serial_and_batch_bundle) {
|
||||
this.dialog.set_value("scan_batch_no", this.item.batch_no);
|
||||
await this.set_data([{ batch_no: this.item.batch_no, qty }]);
|
||||
frappe.model.set_value(this.item.doctype, this.item.name, "batch_no", "");
|
||||
}
|
||||
|
||||
this.dialog.fields_dict.entries.grid.refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -336,10 +329,10 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
item_code: this.item.item_code,
|
||||
serial_nos: upload_serial_nos,
|
||||
},
|
||||
callback: (r) => {
|
||||
callback: async (r) => {
|
||||
if (r.message) {
|
||||
this.dialog.fields_dict.entries.df.data = [];
|
||||
this.set_data(r.message);
|
||||
await this.set_data(r.message);
|
||||
this.update_bundle_entries();
|
||||
}
|
||||
},
|
||||
@@ -522,6 +515,18 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
hidden: 1,
|
||||
});
|
||||
|
||||
if (this.item.type_of_transaction === "Inward") {
|
||||
for (const field of fields) {
|
||||
if (!["serial_no", "batch_no"].includes(field.fieldname)) continue;
|
||||
const reference = field.fieldname;
|
||||
field.fieldtype = "Data";
|
||||
field.fieldname = reference.replace("_no", "_number");
|
||||
field.change = function () {
|
||||
this.doc[reference] = null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
@@ -571,8 +576,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
},
|
||||
callback: (r) => {
|
||||
if (r.message) {
|
||||
this.dialog.fields_dict.entries.df.data = r.message;
|
||||
this.dialog.fields_dict.entries.grid.refresh();
|
||||
this.dialog.fields_dict.entries.df.data = [];
|
||||
this.set_data(r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -584,24 +589,45 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
|
||||
this.dialog.set_value("enter_manually", 0);
|
||||
|
||||
if (this.item.type_of_transaction === "Inward") {
|
||||
const entries = this.dialog.fields_dict.entries.df.data;
|
||||
if (
|
||||
scan_serial_no &&
|
||||
entries.some((row) => row.serial_number?.toUpperCase() === scan_serial_no.toUpperCase())
|
||||
) {
|
||||
frappe.throw(__("Serial No {0} already exists", [scan_serial_no]));
|
||||
}
|
||||
if (scan_serial_no || scan_batch_no) {
|
||||
const batch =
|
||||
!scan_serial_no &&
|
||||
entries.find((row) => row.batch_number?.toUpperCase() === scan_batch_no.toUpperCase());
|
||||
if (batch) batch.qty = flt(batch.qty) + 1;
|
||||
else entries.push({ serial_number: scan_serial_no, batch_number: scan_batch_no, qty: 1 });
|
||||
this.dialog.set_value("scan_serial_no", "");
|
||||
this.dialog.set_value("scan_batch_no", "");
|
||||
this.dialog.fields_dict.entries.grid.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scan_serial_no || scan_batch_no) {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.is_serial_batch_no_exists",
|
||||
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.resolve_scanned_serial_batch_numbers",
|
||||
args: {
|
||||
item_code: this.item.item_code,
|
||||
type_of_transaction: this.item.type_of_transaction,
|
||||
serial_no: scan_serial_no,
|
||||
batch_no: scan_batch_no,
|
||||
},
|
||||
callback: (r) => {
|
||||
this.update_serial_batch_no();
|
||||
this.update_serial_batch_no(r.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
update_serial_batch_no() {
|
||||
const { scan_serial_no, scan_batch_no } = this.dialog.get_values();
|
||||
update_serial_batch_no(result) {
|
||||
const scan_serial_no = result.serial_nos?.[0];
|
||||
const scan_batch_no = result.batch_nos?.[0];
|
||||
|
||||
if (scan_serial_no) {
|
||||
let existing_row = this.dialog.fields_dict.entries.df.data.filter((d) => {
|
||||
@@ -772,7 +798,22 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
set_data(data) {
|
||||
async set_data(data) {
|
||||
if (this.item.type_of_transaction === "Inward") {
|
||||
for (const [field, doctype] of [
|
||||
["serial_no", "Serial No"],
|
||||
["batch_no", "Batch"],
|
||||
]) {
|
||||
const names = data.map((row) => row[field]).filter(Boolean);
|
||||
const labels = names.length
|
||||
? await frappe.xcall("erpnext.stock.serial_batch_identity.get_serial_batch_labels", {
|
||||
doctype,
|
||||
names,
|
||||
})
|
||||
: {};
|
||||
for (const row of data) row[field.replace("_no", "_number")] ||= labels[row[field]];
|
||||
}
|
||||
}
|
||||
data.forEach((d) => {
|
||||
d.qty = Math.abs(d.qty);
|
||||
d.name = d.child_row || d.name;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class InstallationNoteItem(Document):
|
||||
class InstallationNoteItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -3453,8 +3453,8 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
serial_nos_in_bundle = get_serial_nos(dn.packed_items[1].serial_and_batch_bundle)
|
||||
batches_in_bundle = list(get_batches_from_bundle(dn.packed_items[1].serial_and_batch_bundle).keys())
|
||||
|
||||
self.assertEqual(sre_serial_nos, serial_nos_in_bundle)
|
||||
self.assertEqual(sre_batch_nos, batches_in_bundle)
|
||||
self.assertCountEqual(sre_serial_nos, serial_nos_in_bundle)
|
||||
self.assertCountEqual(sre_batch_nos, batches_in_bundle)
|
||||
|
||||
dn.items[0].qty = 5
|
||||
dn.save()
|
||||
@@ -3495,8 +3495,8 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
serial_nos_in_bundle = get_serial_nos(si.packed_items[1].serial_and_batch_bundle)
|
||||
batches_in_bundle = list(get_batches_from_bundle(si.packed_items[1].serial_and_batch_bundle).keys())
|
||||
|
||||
self.assertEqual(sre_serial_nos, serial_nos_in_bundle)
|
||||
self.assertEqual(sre_batch_nos, batches_in_bundle)
|
||||
self.assertCountEqual(sre_serial_nos, serial_nos_in_bundle)
|
||||
self.assertCountEqual(sre_batch_nos, batches_in_bundle)
|
||||
|
||||
si.items[0].qty = 5
|
||||
si.save()
|
||||
|
||||
@@ -16,16 +16,23 @@ from erpnext.stock.utils import scan_barcode
|
||||
|
||||
|
||||
def search_by_term(search_term, warehouse, price_list):
|
||||
result = search_for_serial_or_batch_or_barcode_number(search_term) or {}
|
||||
result = scan_barcode(search_term, allow_multiple=True)
|
||||
if not result or result.get("warehouse"):
|
||||
return
|
||||
matches = result.get("candidates", [result])
|
||||
return {
|
||||
"items": [get_scanned_item(match, warehouse, price_list) for match in matches],
|
||||
"requires_selection": len(matches) > 1,
|
||||
"is_scan": True,
|
||||
}
|
||||
|
||||
item_code = result.get("item_code", search_term)
|
||||
|
||||
def get_scanned_item(result, warehouse, price_list):
|
||||
item_code = result["item_code"]
|
||||
serial_no = result.get("serial_no", "")
|
||||
batch_no = result.get("batch_no", "")
|
||||
barcode = result.get("barcode", "")
|
||||
|
||||
if not result:
|
||||
return
|
||||
|
||||
item_doc = frappe.get_doc("Item", item_code)
|
||||
|
||||
if not item_doc:
|
||||
@@ -109,7 +116,7 @@ def search_by_term(search_term, warehouse, price_list):
|
||||
}
|
||||
)
|
||||
|
||||
return {"items": [item]}
|
||||
return item
|
||||
|
||||
|
||||
def filter_result_items(result, pos_profile):
|
||||
@@ -271,8 +278,10 @@ def get_items(
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def search_for_serial_or_batch_or_barcode_number(search_value: str) -> dict[str, str | None]:
|
||||
return scan_barcode(search_value)
|
||||
def search_for_serial_or_batch_or_barcode_number(
|
||||
search_value: str, item_code: str | None = None, allow_multiple: bool = False
|
||||
) -> dict:
|
||||
return scan_barcode(search_value, {"item_code": item_code}, allow_multiple=allow_multiple)
|
||||
|
||||
|
||||
def get_conditions(search_term, item=None):
|
||||
|
||||
@@ -199,6 +199,7 @@ erpnext.PointOfSale.ItemDetails = class {
|
||||
parent: this.$form_container.find(`.${fieldname}-control`),
|
||||
render_input: true,
|
||||
});
|
||||
this[`${fieldname}_control`].serial_batch_context = { frm: this.events.get_frm(), row: item };
|
||||
this[`${fieldname}_control`].set_value(item[fieldname]);
|
||||
});
|
||||
|
||||
|
||||
@@ -74,11 +74,34 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
const price_list = (doc && doc.selling_price_list) || this.price_list;
|
||||
let { item_group, pos_profile } = this;
|
||||
|
||||
return frappe.call({
|
||||
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items",
|
||||
freeze: true,
|
||||
args: { start, page_length, price_list, item_group, search_term, pos_profile },
|
||||
});
|
||||
const cache_key = JSON.stringify([
|
||||
pos_profile,
|
||||
price_list,
|
||||
item_group,
|
||||
start,
|
||||
page_length,
|
||||
search_term,
|
||||
]);
|
||||
this.items_cache ||= new Map();
|
||||
const scanned = this.barcode_search_pending;
|
||||
this.barcode_search_pending = false;
|
||||
if (!scanned && this.items_cache.has(cache_key)) {
|
||||
return $.Deferred()
|
||||
.resolve({ message: this.items_cache.get(cache_key) })
|
||||
.promise();
|
||||
}
|
||||
return frappe
|
||||
.call({
|
||||
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items",
|
||||
freeze: true,
|
||||
args: { start, page_length, price_list, item_group, search_term, pos_profile },
|
||||
})
|
||||
.then((response) => {
|
||||
if (!scanned && !response.message?.is_scan && response.message?.items?.length) {
|
||||
this.items_cache.set(cache_key, response.message);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
render_item_list(items) {
|
||||
@@ -347,6 +370,7 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
this.search_field.set_focus();
|
||||
this.set_search_value(sScancode);
|
||||
this.barcode_scanned = true;
|
||||
this.barcode_search_pending = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -435,32 +459,14 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
filter_items({ search_term = "" } = {}) {
|
||||
this.start_item_loading_animation();
|
||||
|
||||
const selling_price_list = this.events.get_frm().doc.selling_price_list;
|
||||
|
||||
if (search_term) {
|
||||
search_term = search_term.toLowerCase();
|
||||
|
||||
// memoize
|
||||
this.search_index = this.search_index || {};
|
||||
this.search_index[selling_price_list] = this.search_index[selling_price_list] || {};
|
||||
if (this.search_index[selling_price_list][search_term]) {
|
||||
const items = this.search_index[selling_price_list][search_term];
|
||||
this.items = items;
|
||||
this.render_item_list(items);
|
||||
this.auto_add_item &&
|
||||
this.search_field.$input[0].value &&
|
||||
this.items.length == 1 &&
|
||||
this.add_filtered_item_to_cart();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.get_items({ search_term })
|
||||
.then(({ message }) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { items, serial_no, batch_no, barcode } = message;
|
||||
if (search_term && !barcode) {
|
||||
this.search_index[selling_price_list][search_term] = items;
|
||||
const { items, requires_selection } = message;
|
||||
if (requires_selection) {
|
||||
frappe.show_alert({
|
||||
message: __("Select the item that matches the scanned number."),
|
||||
indicator: "blue",
|
||||
});
|
||||
}
|
||||
this.items = items;
|
||||
this.render_item_list(items);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"autoname": "field:batch_id",
|
||||
"autoname": "hash",
|
||||
"creation": "2013-03-05 14:50:38",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
@@ -44,16 +44,16 @@
|
||||
"report_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.__islocal",
|
||||
"fieldname": "batch_id",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Batch ID",
|
||||
"label": "Batch No",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "batch_id",
|
||||
"oldfieldtype": "Data",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
"search_index": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "item",
|
||||
@@ -215,11 +215,11 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"max_attachments": 5,
|
||||
"modified": "2026-08-21 23:11:39.905227",
|
||||
"modified": "2026-09-09 10:32:25.613814",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Batch",
|
||||
"naming_rule": "By fieldname",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -290,6 +290,8 @@
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "item",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
||||
@@ -12,12 +12,14 @@ from frappe.model.naming import make_autoname, revert_series_if_last
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
from frappe.utils.data import DateTimeLikeObject, add_days
|
||||
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
|
||||
class UnableToSelectBatchError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
def get_name_from_hash():
|
||||
def get_name_from_hash(item_code=None):
|
||||
"""
|
||||
Get a name for a Batch by generating a unique hash.
|
||||
:return: The hash that was generated.
|
||||
@@ -25,7 +27,7 @@ def get_name_from_hash():
|
||||
temp = None
|
||||
while not temp:
|
||||
temp = frappe.generate_hash()[:7].upper()
|
||||
if frappe.db.exists("Batch", temp):
|
||||
if SerialBatchIdentity("Batch").exists(temp, item_code):
|
||||
temp = None
|
||||
|
||||
return temp
|
||||
@@ -114,11 +116,10 @@ class Batch(Document):
|
||||
use_batchwise_valuation: DF.Check
|
||||
# end: auto-generated types
|
||||
|
||||
def autoname(self):
|
||||
"""Generate random ID for batch if not specified"""
|
||||
def before_naming(self):
|
||||
"""Generate a physical batch number when none was entered."""
|
||||
|
||||
if self.batch_id:
|
||||
self.name = self.batch_id
|
||||
return
|
||||
|
||||
create_new_batch, batch_number_series = frappe.db.get_value(
|
||||
@@ -126,7 +127,7 @@ class Batch(Document):
|
||||
)
|
||||
|
||||
if not create_new_batch:
|
||||
frappe.throw(_("Batch ID is mandatory"), frappe.MandatoryError)
|
||||
frappe.throw(_("Batch No is mandatory"), frappe.MandatoryError)
|
||||
|
||||
while not self.batch_id:
|
||||
if batch_number_series:
|
||||
@@ -134,21 +135,22 @@ class Batch(Document):
|
||||
elif batch_uses_naming_series():
|
||||
self.batch_id = self.get_name_from_naming_series()
|
||||
else:
|
||||
self.batch_id = get_name_from_hash()
|
||||
self.batch_id = get_name_from_hash(self.item)
|
||||
|
||||
# User might have manually created a batch with next number
|
||||
if frappe.db.exists("Batch", self.batch_id):
|
||||
if SerialBatchIdentity("Batch").exists(self.batch_id, self.item):
|
||||
self.batch_id = None
|
||||
|
||||
self.name = self.batch_id
|
||||
|
||||
def onload(self):
|
||||
self.image = frappe.db.get_value("Item", self.item, "image")
|
||||
|
||||
def after_delete(self):
|
||||
revert_series_if_last(get_batch_naming_series(), self.name)
|
||||
revert_series_if_last(get_batch_naming_series(), self.batch_id)
|
||||
|
||||
def validate(self):
|
||||
SerialBatchIdentity("Batch").validate(self)
|
||||
if not self.is_new() and frappe.db.get_value("Batch", self.name, "item") != self.item:
|
||||
frappe.throw(_("Item cannot be changed for an existing Batch"))
|
||||
self.item_has_batch_enabled()
|
||||
self.set_batchwise_valuation()
|
||||
|
||||
@@ -478,3 +480,7 @@ def get_batch_no(bundle_id):
|
||||
batches[batch_id] += abs(d.get("qty"))
|
||||
|
||||
return batches
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
SerialBatchIdentity("Batch").sync_constraint()
|
||||
|
||||
@@ -482,7 +482,7 @@ class TestBatch(ERPNextTestSuite):
|
||||
|
||||
if not frappe.db.exists("Batch", batch_name):
|
||||
batch = frappe.get_doc(doctype="Batch", item=item_name, batch_id=batch_name).insert(
|
||||
ignore_permissions=True
|
||||
ignore_permissions=True, set_name=batch_name
|
||||
)
|
||||
batch.save()
|
||||
|
||||
@@ -531,14 +531,15 @@ class TestBatch(ERPNextTestSuite):
|
||||
frappe.set_value("Stock Settings", "Stock Settings", "use_naming_series", 1)
|
||||
|
||||
batch = self.make_new_batch("_Test Stock Item For Batch Test1")
|
||||
batch_name = batch.name
|
||||
batch_name = batch.batch_id
|
||||
|
||||
self.assertNotEqual(batch.name, batch.batch_id)
|
||||
self.assertTrue(batch_name.startswith("BATCH-"))
|
||||
|
||||
batch.delete()
|
||||
batch = self.make_new_batch("_Test Stock Item For Batch Test2")
|
||||
|
||||
self.assertEqual(batch_name, batch.name)
|
||||
self.assertEqual(batch_name, batch.batch_id)
|
||||
|
||||
# reset Stock Settings
|
||||
if not use_naming_series:
|
||||
@@ -714,7 +715,12 @@ class TestBatch(ERPNextTestSuite):
|
||||
get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle),
|
||||
)
|
||||
|
||||
self.assertEqual("BATCHEXISTING002", get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle))
|
||||
self.assertEqual(
|
||||
"BATCHEXISTING002",
|
||||
frappe.db.get_value(
|
||||
"Batch", get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle), "batch_id"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_batch(item_code, rate, create_item_price_for_batch):
|
||||
@@ -771,6 +777,7 @@ def make_new_batch(**args):
|
||||
if args.expiry_date:
|
||||
batch.expiry_date = args.expiry_date
|
||||
|
||||
batch.insert()
|
||||
# Explicit names model batches already referenced by historical transactions.
|
||||
batch.insert(set_name=args.batch_id)
|
||||
|
||||
return batch
|
||||
|
||||
@@ -240,7 +240,7 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
sn_doc.insert(set_name=sn)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
@@ -2059,7 +2059,7 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
sn_doc.insert(set_name=sn)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class DeliveryNoteItem(Document):
|
||||
class DeliveryNoteItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import (
|
||||
SerialNoInventoryDimensionError,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -503,6 +504,7 @@ class TestInventoryDimension(ERPNextTestSuite):
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Inventory Dimension Serial No"
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item.name, [serial_no], create=True)[0]
|
||||
warehouse = create_warehouse("Serialized Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
@@ -563,6 +565,7 @@ class TestInventoryDimension(ERPNextTestSuite):
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Empty Inventory Dimension Serial No"
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item.name, [serial_no], create=True)[0]
|
||||
warehouse = create_warehouse("Serialized Empty Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
@@ -599,6 +602,7 @@ class TestInventoryDimension(ERPNextTestSuite):
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Required Inventory Dimension Serial No"
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item.name, [serial_no], create=True)[0]
|
||||
warehouse = create_warehouse("Serialized Required Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
@@ -644,6 +648,7 @@ class TestInventoryDimension(ERPNextTestSuite):
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Legacy Inventory Dimension Serial No"
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item.name, [serial_no], create=True)[0]
|
||||
warehouse = create_warehouse("Serialized Legacy Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.naming import NamingSeries
|
||||
from frappe.query_builder import Interval
|
||||
from frappe.query_builder.functions import Count, CurDate, UnixTimestamp
|
||||
from frappe.utils import (
|
||||
@@ -483,24 +482,6 @@ class Item(Document):
|
||||
)
|
||||
)
|
||||
|
||||
if self.is_new() and series:
|
||||
obj = NamingSeries(series)
|
||||
prefix = obj.get_prefix()
|
||||
doctype = frappe.qb.DocType("Series")
|
||||
|
||||
query = frappe.qb.from_(doctype).select(doctype.name).where(doctype.name.like(f"{prefix}%"))
|
||||
|
||||
prefix_exists = query.run(as_dict=True)
|
||||
if prefix_exists:
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"The {0} prefix '{1}' already exists. Please change the Serial No Series, otherwise you will get a Duplicate Entry error."
|
||||
).format(bold(frappe.unscrub(field)), bold(prefix)),
|
||||
title=_("Serial No Series Overlap"),
|
||||
indicator="yellow",
|
||||
alert=True,
|
||||
)
|
||||
|
||||
def check_for_active_boms(self):
|
||||
if self.default_bom:
|
||||
bom_item = frappe.db.get_value("BOM", self.default_bom, "item")
|
||||
@@ -641,6 +622,9 @@ class Item(Document):
|
||||
frappe.db.set_value("Item", old_name, "item_name", new_name)
|
||||
|
||||
if merge:
|
||||
from erpnext.stock.serial_batch_identity import validate_item_merge
|
||||
|
||||
validate_item_merge(old_name, new_name)
|
||||
self.validate_properties_before_merge(new_name)
|
||||
self.validate_duplicate_product_bundles_before_merge(old_name, new_name)
|
||||
self.delete_old_bins(old_name)
|
||||
|
||||
@@ -1268,9 +1268,18 @@ class TestItem(ERPNextTestSuite):
|
||||
).name
|
||||
|
||||
serial_no = f"{item}-SN-01"
|
||||
frappe.get_doc(
|
||||
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
|
||||
).insert()
|
||||
serial_no = (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"serial_no": serial_no,
|
||||
"item_code": item,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
|
||||
bundle = make_serial_batch_bundle(
|
||||
|
||||
@@ -21,6 +21,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
|
||||
get_serial_nos_from_bundle,
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import SerialNoValuation
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -433,15 +434,7 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
item_code = "_Test Serialized Item"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
if not frappe.db.exists("Serial No", serial_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": item_code,
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item_code, [serial_no], create=True)[0]
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
@@ -751,27 +744,12 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
"SN-TLCVSNO-0005",
|
||||
]
|
||||
|
||||
for sn in serial_nos:
|
||||
if not frappe.db.exists("Serial No", sn):
|
||||
sn_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": sn_item,
|
||||
"serial_no": sn,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(
|
||||
sn_item, serial_nos, create=True, defaults={"company": "_Test Company"}
|
||||
)
|
||||
|
||||
if not frappe.db.exists("Batch", "BATCH-TLCVSNO-0001"):
|
||||
batch_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"item": batch_item,
|
||||
"batch_id": "BATCH-TLCVSNO-0001",
|
||||
}
|
||||
)
|
||||
batch_doc.insert()
|
||||
batch_no = SerialBatchIdentity("Batch").resolve(batch_item, ["BATCH-TLCVSNO-0001"], create=True)[0]
|
||||
batch_doc = frappe.get_doc("Batch", batch_no)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
@@ -813,7 +791,7 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
if row.item_code == sn_item:
|
||||
row.db_set("serial_no", ", ".join(serial_nos))
|
||||
else:
|
||||
row.db_set("batch_no", "BATCH-TLCVSNO-0001")
|
||||
row.db_set("batch_no", batch_no)
|
||||
|
||||
for sn in serial_nos:
|
||||
sn_doc = frappe.get_doc("Serial No", sn)
|
||||
@@ -902,27 +880,12 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
"SN-TDVLCVSNO-0005",
|
||||
]
|
||||
|
||||
for sn in serial_nos:
|
||||
if not frappe.db.exists("Serial No", sn):
|
||||
sn_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": sn_item,
|
||||
"serial_no": sn,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(
|
||||
sn_item, serial_nos, create=True, defaults={"company": "_Test Company"}
|
||||
)
|
||||
|
||||
if not frappe.db.exists("Batch", "BATCH-TDVLCVSNO-0001"):
|
||||
batch_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"item": batch_item,
|
||||
"batch_id": "BATCH-TDVLCVSNO-0001",
|
||||
}
|
||||
)
|
||||
batch_doc.insert()
|
||||
batch_no = SerialBatchIdentity("Batch").resolve(batch_item, ["BATCH-TDVLCVSNO-0001"], create=True)[0]
|
||||
batch_doc = frappe.get_doc("Batch", batch_no)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
@@ -974,7 +937,7 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
if row.item_code == sn_item:
|
||||
row.db_set("serial_no", ", ".join(serial_nos))
|
||||
else:
|
||||
row.db_set("batch_no", "BATCH-TDVLCVSNO-0001")
|
||||
row.db_set("batch_no", batch_no)
|
||||
|
||||
stock_ledger_entries = frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name})
|
||||
for sle in stock_ledger_entries:
|
||||
@@ -982,7 +945,7 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
if doc.item_code == sn_item:
|
||||
doc.db_set("serial_no", ", ".join(serial_nos))
|
||||
else:
|
||||
doc.db_set("batch_no", "BATCH-TDVLCVSNO-0001")
|
||||
doc.db_set("batch_no", batch_no)
|
||||
|
||||
dn = create_delivery_note(
|
||||
company=company,
|
||||
@@ -1017,14 +980,14 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
if doc.item_code == sn_item:
|
||||
doc.db_set("serial_no", ", ".join(serial_nos))
|
||||
else:
|
||||
doc.db_set("batch_no", "BATCH-TDVLCVSNO-0001")
|
||||
doc.db_set("batch_no", batch_no)
|
||||
|
||||
available_batches = get_auto_batch_nos(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": batch_item,
|
||||
"warehouse": warehouse,
|
||||
"batch_no": ["BATCH-TDVLCVSNO-0001"],
|
||||
"batch_no": [batch_no],
|
||||
"consider_negative_batches": True,
|
||||
}
|
||||
)
|
||||
@@ -1092,17 +1055,9 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
"SN-ALCVTDVLCVSNO-0005",
|
||||
]
|
||||
|
||||
for sn in serial_nos:
|
||||
if not frappe.db.exists("Serial No", sn):
|
||||
sn_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": sn_item,
|
||||
"serial_no": sn,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(
|
||||
sn_item, serial_nos, create=True, defaults={"company": "_Test Company"}
|
||||
)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
|
||||
@@ -9,13 +9,13 @@ import json
|
||||
import frappe
|
||||
import frappe.defaults
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.get_item_details import get_item_details, get_price_list_rate
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PackedItem(Document):
|
||||
class PackedItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ class TestPickList(ERPNextTestSuite):
|
||||
"item_code": "_Test Serialized Item",
|
||||
"serial_no": serial_no,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no)
|
||||
|
||||
stock_reconciliation = frappe.get_doc(
|
||||
{
|
||||
@@ -1152,7 +1152,7 @@ class TestPickList(ERPNextTestSuite):
|
||||
"batch_id": batch_id,
|
||||
"item": item,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=batch_id)
|
||||
|
||||
make_stock_entry(
|
||||
item=item,
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PickListItem(Document):
|
||||
class PickListItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -1093,7 +1093,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"serial_no": serial_no[0],
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no[0])
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=1, serial_no=serial_no)
|
||||
pr_doc.load_from_db()
|
||||
@@ -3144,6 +3144,10 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"SNU-TSFISI-000014",
|
||||
"SNU-TSFISI-000015",
|
||||
]
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(item_code, serial_nos, create=True)
|
||||
removed_serial = serial_nos[-1]
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
@@ -3162,7 +3166,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
for row in sbb_doc.entries:
|
||||
self.assertIn(row.serial_no, serial_nos)
|
||||
|
||||
serial_nos.remove("SNU-TSFISI-000015")
|
||||
serial_nos.remove(removed_serial)
|
||||
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
@@ -3191,7 +3195,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
self.assertTrue(sr.items[0].current_serial_and_batch_bundle)
|
||||
self.assertTrue(sr.items[0].serial_and_batch_bundle)
|
||||
|
||||
serial_no_status = frappe.db.get_value("Serial No", "SNU-TSFISI-000015", "status")
|
||||
serial_no_status = frappe.db.get_value("Serial No", removed_serial, "status")
|
||||
|
||||
self.assertNotEqual(serial_no_status, "Active")
|
||||
|
||||
@@ -3443,7 +3447,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"batch_id": batch_no,
|
||||
"item": batch_item,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=batch_no)
|
||||
|
||||
for serial_no in serial_nos:
|
||||
if not frappe.db.exists("Serial No", serial_no):
|
||||
@@ -3454,7 +3458,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=batch_item,
|
||||
@@ -4762,7 +4766,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"batch_id": batch_no,
|
||||
"item": batch_item,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=batch_no)
|
||||
|
||||
for serial_no in serial_nos:
|
||||
if not frappe.db.exists("Serial No", serial_no):
|
||||
@@ -4773,7 +4777,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=batch_item,
|
||||
@@ -5738,7 +5742,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"batch_id": "BN-TESTDNUBVWF-00001",
|
||||
"item": item_code,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="BN-TESTDNUBVWF-00001")
|
||||
|
||||
doc.db_set("use_batchwise_valuation", 0)
|
||||
doc.reload()
|
||||
@@ -5751,7 +5755,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"batch_id": "BN-TESTDNUBVWF-00002",
|
||||
"item": item_code,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="BN-TESTDNUBVWF-00002")
|
||||
|
||||
self.assertEqual(doc.use_batchwise_valuation, 1)
|
||||
|
||||
@@ -5881,7 +5885,11 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
).name
|
||||
|
||||
batch_no = "BN-TPRBWV-00001"
|
||||
batch = frappe.new_doc("Batch").update({"batch_id": batch_no, "item": item_code}).insert()
|
||||
batch = (
|
||||
frappe.new_doc("Batch")
|
||||
.update({"batch_id": batch_no, "item": item_code})
|
||||
.insert(set_name=batch_no)
|
||||
)
|
||||
self.assertEqual(batch.use_batchwise_valuation, 1)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PurchaseReceiptItem(Document):
|
||||
class PurchaseReceiptItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -9,9 +9,8 @@ from frappe.utils import cint, flt, parse_json
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
create_serial_batch_no_ledgers,
|
||||
get_type_of_transaction,
|
||||
make_batch_nos,
|
||||
make_serial_nos,
|
||||
)
|
||||
from erpnext.stock.serial_batch_identity import add_number_labels, resolve_number_entries
|
||||
|
||||
SUPPORTED_VOUCHER_TYPES = frozenset(
|
||||
[
|
||||
@@ -36,8 +35,14 @@ def get_bundle_entries(bundle: str, start: int = 0, page_length: int = 50, searc
|
||||
page_length = min(cint(page_length) or 50, 500)
|
||||
|
||||
table = frappe.qb.DocType("Serial and Batch Entry")
|
||||
serial = frappe.qb.DocType("Serial No")
|
||||
batch = frappe.qb.DocType("Batch")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.left_join(serial)
|
||||
.on(serial.name == table.serial_no)
|
||||
.left_join(batch)
|
||||
.on(batch.name == table.batch_no)
|
||||
.select(table.name, table.serial_no, table.batch_no, table.qty)
|
||||
.where(table.parent == bundle)
|
||||
.orderby(table.idx)
|
||||
@@ -47,9 +52,9 @@ def get_bundle_entries(bundle: str, start: int = 0, page_length: int = 50, searc
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.where((table.serial_no.like(search_term)) | (table.batch_no.like(search_term)))
|
||||
query = query.where(serial.serial_no.like(search_term) | batch.batch_id.like(search_term))
|
||||
|
||||
entries = query.run(as_dict=True)
|
||||
entries = add_number_labels(query.run(as_dict=True))
|
||||
summary = get_bundle_summary(bundle)
|
||||
summary["entries"] = entries
|
||||
|
||||
@@ -82,13 +87,13 @@ def download_bundle_entries_csv(bundle: str):
|
||||
item = frappe.get_cached_value("Item", doc.item_code, ["has_serial_no", "has_batch_no"], as_dict=True)
|
||||
|
||||
rows = [get_csv_columns(item)]
|
||||
for entry in doc.entries:
|
||||
for entry in add_number_labels(doc.entries):
|
||||
if item.has_serial_no and item.has_batch_no:
|
||||
rows.append([entry.serial_no, entry.batch_no, abs(entry.qty)])
|
||||
rows.append([entry.serial_number, entry.batch_number, abs(entry.qty)])
|
||||
elif item.has_batch_no:
|
||||
rows.append([entry.batch_no, abs(entry.qty)])
|
||||
rows.append([entry.batch_number, abs(entry.qty)])
|
||||
else:
|
||||
rows.append([entry.serial_no])
|
||||
rows.append([entry.serial_number])
|
||||
|
||||
build_csv_response(rows, f"{bundle}-entries")
|
||||
|
||||
@@ -132,9 +137,9 @@ def upsert_bundle_entries(
|
||||
frappe.throw(_("Please add at least one Serial No or Batch to save"))
|
||||
|
||||
frappe.has_permission(doc.get("doctype"), "write", throw=True)
|
||||
if get_type_of_transaction(doc, child_row) == "Inward":
|
||||
make_serial_nos(child_row.item_code, entries)
|
||||
make_batch_nos(child_row.item_code, entries)
|
||||
resolve_number_entries(
|
||||
child_row.item_code, entries, create=get_type_of_transaction(doc, child_row) == "Inward"
|
||||
)
|
||||
|
||||
bundle = create_serial_batch_no_ledgers(entries, child_row, doc)
|
||||
|
||||
@@ -175,6 +180,10 @@ def apply_incremental_changes(bundle_name, child_row, entries, deleted, replace=
|
||||
)
|
||||
)
|
||||
|
||||
if child_row.item_code != bundle.item_code:
|
||||
frappe.throw(_("The bundle belongs to a different item"))
|
||||
|
||||
resolve_number_entries(bundle.item_code, entries, create=bundle.type_of_transaction == "Inward")
|
||||
sign = 1 if bundle.type_of_transaction == "Inward" else -1
|
||||
|
||||
if replace:
|
||||
@@ -198,11 +207,6 @@ def apply_incremental_changes(bundle_name, child_row, entries, deleted, replace=
|
||||
if row.get("serial_no"):
|
||||
entry.serial_no = row.get("serial_no")
|
||||
|
||||
if entries and bundle.type_of_transaction == "Inward":
|
||||
incoming = [frappe._dict(row) for row in entries]
|
||||
make_serial_nos(child_row.item_code, incoming)
|
||||
make_batch_nos(child_row.item_code, incoming)
|
||||
|
||||
for row in new_rows:
|
||||
bundle.append(
|
||||
"entries",
|
||||
|
||||
@@ -15,6 +15,7 @@ from frappe.query_builder.functions import Concat_ws, Max, Sum
|
||||
from frappe.utils import (
|
||||
cint,
|
||||
cstr,
|
||||
escape_html,
|
||||
flt,
|
||||
format_datetime,
|
||||
get_datetime,
|
||||
@@ -34,6 +35,8 @@ from erpnext.stock.serial_batch_bundle import (
|
||||
get_batches_from_bundle,
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle
|
||||
from erpnext.stock.serial_batch_display import format_serial_batch_numbers
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity, add_number_labels, resolve_number_entries
|
||||
from erpnext.stock.valuation import FIFOValuation
|
||||
|
||||
|
||||
@@ -169,7 +172,7 @@ class SerialandBatchBundle(Document):
|
||||
"You cannot outward the following {0} as they are either Delivered, Inactive or located in a different warehouse."
|
||||
).format(_("Serial Nos") if len(invalid_serial_nos) > 1 else _("Serial No"))
|
||||
msg += "<hr>"
|
||||
msg += ", ".join(sn for sn in invalid_serial_nos)
|
||||
msg += format_serial_batch_numbers("Serial No", invalid_serial_nos)
|
||||
frappe.throw(msg)
|
||||
|
||||
def validate_voucher_detail_no(self):
|
||||
@@ -230,7 +233,7 @@ class SerialandBatchBundle(Document):
|
||||
_(
|
||||
"You cannot process the serial number {0} as it has already been used in the SABB {1}. {2} If you want to inward the same serial number multiple times, then enable 'Allow existing Serial No to be Manufactured/Received again' in the {3}"
|
||||
).format(
|
||||
row.serial_no,
|
||||
format_serial_batch_numbers("Serial No", [row.serial_no]),
|
||||
get_link_to_form("Serial and Batch Bundle", row.parent),
|
||||
note,
|
||||
get_link_to_form("Stock Settings", "Stock Settings"),
|
||||
@@ -310,10 +313,11 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
for serial_no in serial_nos:
|
||||
if not serial_no_warehouse.get(serial_no) or serial_no_warehouse.get(serial_no) != self.warehouse:
|
||||
serial_number = format_serial_batch_numbers("Serial No", [serial_no])
|
||||
reservation = get_serial_no_reservation(self.item_code, serial_no, self.warehouse)
|
||||
if reservation:
|
||||
self.throw_error_message(
|
||||
f"Serial No {bold(serial_no)} is in warehouse {bold(self.warehouse)}"
|
||||
f"Serial No {bold(serial_number)} is in warehouse {bold(self.warehouse)}"
|
||||
f" but is reserved for {reservation.voucher_type} {bold(reservation.voucher_no)}"
|
||||
f" via {get_link_to_form('Stock Reservation Entry', reservation.name)}."
|
||||
f" Please use an unreserved serial number or cancel the reservation.",
|
||||
@@ -321,7 +325,9 @@ class SerialandBatchBundle(Document):
|
||||
)
|
||||
else:
|
||||
self.throw_error_message(
|
||||
f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.",
|
||||
_("Serial No {0} is not present in the warehouse {1}.").format(
|
||||
bold(serial_number), bold(self.warehouse)
|
||||
),
|
||||
SerialNoWarehouseError,
|
||||
)
|
||||
|
||||
@@ -359,7 +365,9 @@ class SerialandBatchBundle(Document):
|
||||
for data in available_serial_nos:
|
||||
if data.serial_no in serial_nos:
|
||||
self.throw_error_message(
|
||||
f"Serial No {bold(data.serial_no)} is already present in the warehouse {bold(data.warehouse)}.",
|
||||
_("Serial No {0} is already present in the warehouse {1}.").format(
|
||||
bold(format_serial_batch_numbers("Serial No", [data.serial_no])), bold(data.warehouse)
|
||||
),
|
||||
SerialNoDuplicateError,
|
||||
)
|
||||
|
||||
@@ -378,13 +386,13 @@ class SerialandBatchBundle(Document):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Serial No {0} is already Delivered. You cannot use it again in Manufacture / Repack entry."
|
||||
).format(bold(serial_nos[0]))
|
||||
).format(bold(format_serial_batch_numbers("Serial No", [serial_nos[0]])))
|
||||
)
|
||||
else:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Serial Nos {0} are already Delivered. You cannot use them again in Manufacture / Repack entry."
|
||||
).format(bold(", ".join(serial_nos)))
|
||||
).format(bold(format_serial_batch_numbers("Serial No", serial_nos)))
|
||||
)
|
||||
|
||||
def throw_error_message(self, message, exception=frappe.ValidationError):
|
||||
@@ -533,14 +541,22 @@ class SerialandBatchBundle(Document):
|
||||
self.throw_error_message(
|
||||
_(
|
||||
"Serial No {0} is not present in the {1} {2}, hence you can't return it against the {1} {2}"
|
||||
).format(bold(row.serial_no), self.voucher_type, bold(return_against))
|
||||
).format(
|
||||
bold(format_serial_batch_numbers("Serial No", [row.serial_no])),
|
||||
self.voucher_type,
|
||||
bold(return_against),
|
||||
)
|
||||
)
|
||||
|
||||
if row.batch_no and row.batch_no not in original_inv_details["batches"]:
|
||||
self.throw_error_message(
|
||||
_(
|
||||
"Batch No {0} is not present in the original {1} {2}, hence you can't return it against the {1} {2}"
|
||||
).format(bold(row.batch_no), self.voucher_type, bold(return_against))
|
||||
).format(
|
||||
bold(format_serial_batch_numbers("Batch", [row.batch_no])),
|
||||
self.voucher_type,
|
||||
bold(return_against),
|
||||
)
|
||||
)
|
||||
|
||||
def get_valuation_rate_for_return_entry(self, return_against):
|
||||
@@ -772,7 +788,10 @@ class SerialandBatchBundle(Document):
|
||||
if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty):
|
||||
frappe.throw(
|
||||
_("Batch No {0} of Item {1} has negative stock of quantity {2} in the warehouse {3}").format(
|
||||
bold(batch_no), bold(self.item_code), bold(available_qty), self.warehouse
|
||||
bold(format_serial_batch_numbers("Batch", [batch_no])),
|
||||
bold(self.item_code),
|
||||
bold(available_qty),
|
||||
self.warehouse,
|
||||
),
|
||||
BatchNegativeStockError,
|
||||
)
|
||||
@@ -1092,11 +1111,12 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
msg += "<br><br><ul>"
|
||||
|
||||
add_number_labels(future_entries)
|
||||
for d in future_entries:
|
||||
if self.has_serial_no:
|
||||
msg += f"<li>{d.serial_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
|
||||
msg += f"<li>{escape_html(d.serial_number or '')} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
|
||||
else:
|
||||
msg += f"<li>{d.batch_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
|
||||
msg += f"<li>{escape_html(d.batch_number or '')} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
|
||||
msg += "</li></ul>"
|
||||
|
||||
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError)
|
||||
@@ -1282,7 +1302,7 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
frappe.throw(
|
||||
_("At row {0}: Qty is mandatory for the batch {1}").format(
|
||||
bold(row.idx), bold(row.batch_no)
|
||||
bold(row.idx), bold(format_serial_batch_numbers("Batch", [row.batch_no]))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1333,7 +1353,10 @@ class SerialandBatchBundle(Document):
|
||||
for serial_no, batch_no in serial_batches.items():
|
||||
if correct_batches.get(serial_no) and correct_batches.get(serial_no) != batch_no:
|
||||
self.throw_error_message(
|
||||
f"Serial No {bold(serial_no)} does not belong to Batch No {bold(batch_no)}"
|
||||
_("Serial No {0} does not belong to Batch No {1}").format(
|
||||
bold(format_serial_batch_numbers("Serial No", [serial_no])),
|
||||
bold(format_serial_batch_numbers("Batch", [batch_no])),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_incorrect_serial_nos(self, serial_nos):
|
||||
@@ -1344,9 +1367,13 @@ class SerialandBatchBundle(Document):
|
||||
)
|
||||
|
||||
if incorrect_serial_nos:
|
||||
incorrect_serial_nos = ", ".join([d.name for d in incorrect_serial_nos])
|
||||
incorrect_serial_nos = format_serial_batch_numbers(
|
||||
"Serial No", [d.name for d in incorrect_serial_nos]
|
||||
)
|
||||
self.throw_error_message(
|
||||
f"Serial Nos {bold(incorrect_serial_nos)} does not belong to Item {bold(self.item_code)}"
|
||||
_("Serial Nos {0} does not belong to Item {1}").format(
|
||||
bold(incorrect_serial_nos), bold(self.item_code)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_incorrect_batch_nos(self, batch_nos):
|
||||
@@ -1355,9 +1382,11 @@ class SerialandBatchBundle(Document):
|
||||
)
|
||||
|
||||
if incorrect_batch_nos:
|
||||
incorrect_batch_nos = ", ".join([d.name for d in incorrect_batch_nos])
|
||||
incorrect_batch_nos = format_serial_batch_numbers("Batch", [d.name for d in incorrect_batch_nos])
|
||||
self.throw_error_message(
|
||||
f"Batch Nos {bold(incorrect_batch_nos)} does not belong to Item {bold(self.item_code)}"
|
||||
_("Batch Nos {0} does not belong to Item {1}").format(
|
||||
bold(incorrect_batch_nos), bold(self.item_code)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_serial_and_batch_no_for_returned(self):
|
||||
@@ -1399,13 +1428,17 @@ class SerialandBatchBundle(Document):
|
||||
if serial_nos:
|
||||
if not set(current_serial_nos).issubset(set(serial_nos)):
|
||||
self.throw_error_message(
|
||||
f"Serial Nos {bold(', '.join(serial_nos))} are not part of the original document."
|
||||
_("Serial Nos {0} are not part of the original document.").format(
|
||||
bold(format_serial_batch_numbers("Serial No", serial_nos))
|
||||
)
|
||||
)
|
||||
|
||||
if batches:
|
||||
if not set(current_batches).issubset(set(batches)):
|
||||
self.throw_error_message(
|
||||
f"Batch Nos {bold(', '.join(batches))} are not part of the original document."
|
||||
_("Batch Nos {0} are not part of the original document.").format(
|
||||
bold(format_serial_batch_numbers("Batch", batches))
|
||||
)
|
||||
)
|
||||
|
||||
def get_orignal_document_data(self):
|
||||
@@ -1433,12 +1466,18 @@ class SerialandBatchBundle(Document):
|
||||
if serial_nos:
|
||||
for key, value in collections.Counter(serial_nos).items():
|
||||
if value > 1:
|
||||
self.throw_error_message(f"Duplicate Serial No {key} found")
|
||||
self.throw_error_message(
|
||||
_("Duplicate Serial No {0} found").format(
|
||||
format_serial_batch_numbers("Serial No", [key])
|
||||
)
|
||||
)
|
||||
|
||||
if batch_nos:
|
||||
for key, value in collections.Counter(batch_nos).items():
|
||||
if value > 1:
|
||||
self.throw_error_message(f"Duplicate Batch No {key} found")
|
||||
self.throw_error_message(
|
||||
_("Duplicate Batch No {0} found").format(format_serial_batch_numbers("Batch", [key]))
|
||||
)
|
||||
|
||||
def before_cancel(self):
|
||||
self.delink_serial_and_batch_bundle()
|
||||
@@ -1631,7 +1670,9 @@ class SerialandBatchBundle(Document):
|
||||
self.validate_negative_batch(batch_no, available_batches[batch_no])
|
||||
|
||||
self.throw_error_message(
|
||||
f"Batch {bold(batch_no)} is not available in the selected warehouse {self.warehouse}"
|
||||
_("Batch {0} is not available in the selected warehouse {1}").format(
|
||||
bold(format_serial_batch_numbers("Batch", [batch_no])), self.warehouse
|
||||
)
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
@@ -1710,7 +1751,7 @@ class SerialandBatchBundle(Document):
|
||||
"However, enabling this setting may lead to negative stock in the system. "
|
||||
"So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate."
|
||||
).format(
|
||||
bold(batch_no),
|
||||
bold(format_serial_batch_numbers("Batch", [batch_no])),
|
||||
bold(self.item_code),
|
||||
bold(self.warehouse),
|
||||
date_msg,
|
||||
@@ -1986,19 +2027,19 @@ def parse_csv_file_to_get_serial_batch(reader):
|
||||
continue
|
||||
|
||||
if has_serial_no or (has_serial_no and has_batch_no):
|
||||
_dict = {"serial_no": row[0].strip(), "qty": 1}
|
||||
_dict = {"serial_number": row[0].strip(), "qty": 1}
|
||||
|
||||
if has_batch_no:
|
||||
_dict.update(
|
||||
{
|
||||
"batch_no": row[1].strip(),
|
||||
"batch_number": row[1].strip(),
|
||||
"qty": row[2],
|
||||
}
|
||||
)
|
||||
|
||||
batch_nos.append(
|
||||
{
|
||||
"batch_no": row[1].strip(),
|
||||
"batch_number": row[1].strip(),
|
||||
"qty": row[2],
|
||||
}
|
||||
)
|
||||
@@ -2007,7 +2048,7 @@ def parse_csv_file_to_get_serial_batch(reader):
|
||||
elif has_batch_no:
|
||||
batch_nos.append(
|
||||
{
|
||||
"batch_no": row[0].strip(),
|
||||
"batch_number": row[0].strip(),
|
||||
"qty": row[1],
|
||||
}
|
||||
)
|
||||
@@ -2023,7 +2064,7 @@ def get_serial_batch_from_data(item_code, kwargs):
|
||||
for serial_no in data:
|
||||
if not serial_no:
|
||||
continue
|
||||
serial_nos.append({"serial_no": serial_no, "qty": 1})
|
||||
serial_nos.append({"serial_number": serial_no, "qty": 1})
|
||||
|
||||
make_serial_nos(item_code, serial_nos)
|
||||
|
||||
@@ -2047,106 +2088,12 @@ def create_serial_nos(item_code: str, serial_nos: list | str):
|
||||
|
||||
|
||||
def make_serial_nos(item_code, serial_nos):
|
||||
item = frappe.get_cached_value(
|
||||
"Item", item_code, ["description", "item_code", "item_name", "warranty_period"], as_dict=1
|
||||
)
|
||||
|
||||
serial_nos = [d.get("serial_no").strip() for d in serial_nos if d.get("serial_no")]
|
||||
existing_serial_nos = frappe.get_all("Serial No", filters={"name": ("in", serial_nos)})
|
||||
|
||||
existing_serial_nos = [d.get("name") for d in existing_serial_nos if d.get("name")]
|
||||
serial_nos = list(set(serial_nos) - set(existing_serial_nos))
|
||||
|
||||
if not serial_nos:
|
||||
return
|
||||
|
||||
serial_nos_details = []
|
||||
user = frappe.session.user
|
||||
for serial_no in serial_nos:
|
||||
serial_nos_details.append(
|
||||
(
|
||||
serial_no,
|
||||
serial_no,
|
||||
now(),
|
||||
now(),
|
||||
user,
|
||||
user,
|
||||
item.item_code,
|
||||
item.item_name,
|
||||
item.description,
|
||||
item.warranty_period or 0,
|
||||
"Inactive",
|
||||
)
|
||||
)
|
||||
|
||||
fields = [
|
||||
"name",
|
||||
"serial_no",
|
||||
"creation",
|
||||
"modified",
|
||||
"owner",
|
||||
"modified_by",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
"warranty_period",
|
||||
"status",
|
||||
]
|
||||
|
||||
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
|
||||
|
||||
frappe.msgprint(_("Serial Nos are created successfully"), alert=True)
|
||||
"""Resolve explicit physical numbers and populate the entry links."""
|
||||
resolve_number_entries(item_code, serial_nos, create=True)
|
||||
|
||||
|
||||
def make_batch_nos(item_code, batch_nos):
|
||||
item = frappe.get_cached_value("Item", item_code, ["description", "item_code"], as_dict=1)
|
||||
batch_nos = [d.get("batch_no") for d in batch_nos if d.get("batch_no")]
|
||||
|
||||
existing_batches = frappe.get_all("Batch", filters={"name": ("in", batch_nos)})
|
||||
|
||||
existing_batches = [d.get("name") for d in existing_batches if d.get("name")]
|
||||
|
||||
batch_nos = list(set(batch_nos) - set(existing_batches))
|
||||
if not batch_nos:
|
||||
return
|
||||
|
||||
batch_nos_details = []
|
||||
user = frappe.session.user
|
||||
for batch_no in batch_nos:
|
||||
if frappe.db.exists("Batch", batch_no):
|
||||
continue
|
||||
|
||||
batch_nos_details.append(
|
||||
(
|
||||
batch_no,
|
||||
batch_no,
|
||||
now(),
|
||||
now(),
|
||||
user,
|
||||
user,
|
||||
item.item_code,
|
||||
item.item_name,
|
||||
item.description,
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
fields = [
|
||||
"name",
|
||||
"batch_id",
|
||||
"creation",
|
||||
"modified",
|
||||
"owner",
|
||||
"modified_by",
|
||||
"item",
|
||||
"item_name",
|
||||
"description",
|
||||
"use_batchwise_valuation",
|
||||
]
|
||||
|
||||
frappe.db.bulk_insert("Batch", fields=fields, values=set(batch_nos_details))
|
||||
|
||||
frappe.msgprint(_("Batch Nos are created successfully"), alert=True)
|
||||
resolve_number_entries(item_code, batch_nos, create=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -2266,6 +2213,10 @@ def add_serial_batch_ledgers(
|
||||
if parent_doc and isinstance(parent_doc, str):
|
||||
parent_doc = parse_json(parent_doc)
|
||||
|
||||
resolve_number_entries(
|
||||
child_row.item_code, entries, create=get_type_of_transaction(parent_doc, child_row) == "Inward"
|
||||
)
|
||||
|
||||
bundle = child_row.serial_and_batch_bundle
|
||||
if child_row.get("is_rejected"):
|
||||
bundle = child_row.rejected_serial_and_batch_bundle
|
||||
@@ -2472,11 +2423,15 @@ def get_serial_and_batch_ledger(**kwargs):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_auto_data(**kwargs):
|
||||
from erpnext.stock.serial_batch_identity import add_number_labels
|
||||
|
||||
kwargs = frappe._dict(kwargs)
|
||||
data = []
|
||||
if cint(kwargs.has_serial_no):
|
||||
return get_serial_nos_from_sre(kwargs) if kwargs.scio_detail else get_available_serial_nos(kwargs)
|
||||
data = get_serial_nos_from_sre(kwargs) if kwargs.scio_detail else get_available_serial_nos(kwargs)
|
||||
elif cint(kwargs.has_batch_no):
|
||||
return get_batch_nos_from_sre(kwargs) if kwargs.scio_detail else get_auto_batch_nos(kwargs)
|
||||
data = get_batch_nos_from_sre(kwargs) if kwargs.scio_detail else get_auto_batch_nos(kwargs)
|
||||
return add_number_labels(data or [])
|
||||
|
||||
|
||||
def get_available_batches_qty(available_batches):
|
||||
@@ -2832,7 +2787,11 @@ def get_reserved_serial_nos_for_voucher(kwargs, reserved_entries, reserved_vouch
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Serial No {0} is reserved against the {1} {2} and cannot be used for any other transaction."
|
||||
).format(bold(entry.serial_no), entry.voucher_type, bold(entry.voucher_no)),
|
||||
).format(
|
||||
bold(format_serial_batch_numbers("Serial No", [entry.serial_no])),
|
||||
entry.voucher_type,
|
||||
bold(entry.voucher_no),
|
||||
),
|
||||
title=_("Serial No Reserved"),
|
||||
)
|
||||
|
||||
@@ -3693,35 +3652,25 @@ def get_batch_no_from_serial_no(serial_no: str):
|
||||
return frappe.get_cached_value("Serial No", serial_no, "batch_no")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_serial_batch_no_exists(
|
||||
item_code: str, type_of_transaction: str, serial_no: str | None = None, batch_no: str | None = None
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def resolve_scanned_serial_batch_numbers(
|
||||
item_code: str, serial_no: str | None = None, batch_no: str | None = None
|
||||
):
|
||||
if serial_no and not frappe.db.exists("Serial No", serial_no):
|
||||
if type_of_transaction != "Inward":
|
||||
frappe.throw(_("Serial No {0} does not exist").format(serial_no))
|
||||
from erpnext.stock.serial_batch_identity import resolve_serial_batch_numbers
|
||||
|
||||
make_serial_no(serial_no, item_code)
|
||||
|
||||
if batch_no and not frappe.db.exists("Batch", batch_no):
|
||||
if type_of_transaction != "Inward":
|
||||
frappe.throw(_("Batch No {0} does not exist").format(batch_no))
|
||||
|
||||
make_batch_no(batch_no, item_code)
|
||||
return resolve_serial_batch_numbers(
|
||||
item_code,
|
||||
serial_numbers=[serial_no] if serial_no else [],
|
||||
batch_numbers=[batch_no] if batch_no else [],
|
||||
)
|
||||
|
||||
|
||||
def make_serial_no(serial_no, item_code):
|
||||
serial_no_doc = frappe.new_doc("Serial No")
|
||||
serial_no_doc.serial_no = serial_no
|
||||
serial_no_doc.item_code = item_code
|
||||
serial_no_doc.save(ignore_permissions=True)
|
||||
return SerialBatchIdentity("Serial No").resolve(item_code, [serial_no], create=True)[0]
|
||||
|
||||
|
||||
def make_batch_no(batch_no, item_code):
|
||||
batch_doc = frappe.new_doc("Batch")
|
||||
batch_doc.batch_id = batch_no
|
||||
batch_doc.item = item_code
|
||||
batch_doc.save(ignore_permissions=True)
|
||||
return SerialBatchIdentity("Batch").resolve(item_code, [batch_no], create=True)[0]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -39,26 +39,26 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
|
||||
self.assertTrue(frappe.db.exists("Serial and Batch Bundle", summary.bundle))
|
||||
self.assertEqual(summary.total_count, 2)
|
||||
self.assertEqual(summary.total_qty, 2)
|
||||
for serial_no in serials:
|
||||
self.assertTrue(frappe.db.exists("Serial No", serial_no))
|
||||
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item, "serial_no": serial_no}))
|
||||
|
||||
def test_incremental_append_preserves_existing_entries(self):
|
||||
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
|
||||
pr = self.make_draft_pr(item, qty=3)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(3)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": serials[0]}, {"serial_no": serials[1]}])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": serials[0]}, {"serial_number": serials[1]}])
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
first_entry_names = set(
|
||||
frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")
|
||||
)
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": serials[2]}])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": serials[2]}])
|
||||
second_entry_names = set(
|
||||
frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")
|
||||
)
|
||||
@@ -71,17 +71,24 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
|
||||
to_delete = frappe.get_all(
|
||||
"Serial and Batch Entry", {"parent": summary.bundle, "serial_no": serials[0]}, pluck="name"
|
||||
"Serial and Batch Entry",
|
||||
{
|
||||
"parent": summary.bundle,
|
||||
"serial_no": frappe.db.get_value("Serial No", {"item_code": item, "serial_no": serials[0]}),
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
summary = self.upsert(pr, deleted=to_delete)
|
||||
|
||||
self.assertEqual(summary.total_count, 1)
|
||||
remaining = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="serial_no")
|
||||
self.assertEqual(remaining, [serials[1]])
|
||||
self.assertEqual(
|
||||
remaining, [frappe.db.get_value("Serial No", {"item_code": item, "serial_no": serials[1]})]
|
||||
)
|
||||
|
||||
def test_batch_qty_update(self):
|
||||
item = make_item(
|
||||
@@ -111,14 +118,21 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
old_serial = f"SN-{frappe.generate_hash(length=8)}"
|
||||
new_serial = f"SN-{frappe.generate_hash(length=8)}"
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": old_serial}])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": old_serial}])
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
entry_name = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")[0]
|
||||
|
||||
self.upsert(pr, entries=[{"name": entry_name, "serial_no": new_serial}])
|
||||
self.upsert(pr, entries=[{"name": entry_name, "serial_number": new_serial}])
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Serial and Batch Entry", entry_name, "serial_no"), new_serial)
|
||||
self.assertTrue(frappe.db.exists("Serial No", new_serial))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Serial No",
|
||||
frappe.db.get_value("Serial and Batch Entry", entry_name, "serial_no"),
|
||||
"serial_no",
|
||||
),
|
||||
new_serial,
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item, "serial_no": new_serial}))
|
||||
|
||||
def test_auto_create_missing_batch_no(self):
|
||||
item = make_item(properties={"is_stock_item": 1, "has_batch_no": 1}).name
|
||||
@@ -126,14 +140,14 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
batch1 = f"BNEW-{frappe.generate_hash(length=8)}"
|
||||
batch2 = f"BNEW-{frappe.generate_hash(length=8)}"
|
||||
|
||||
self.assertFalse(frappe.db.exists("Batch", batch1))
|
||||
summary = self.upsert(pr, entries=[{"batch_no": batch1, "qty": 4}])
|
||||
self.assertTrue(frappe.db.exists("Batch", batch1))
|
||||
self.assertFalse(frappe.db.exists("Batch", {"item": item, "batch_id": batch1}))
|
||||
summary = self.upsert(pr, entries=[{"batch_number": batch1, "qty": 4}])
|
||||
self.assertTrue(frappe.db.exists("Batch", {"item": item, "batch_id": batch1}))
|
||||
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
summary = self.upsert(pr, entries=[{"batch_no": batch2, "qty": 1}])
|
||||
summary = self.upsert(pr, entries=[{"batch_number": batch2, "qty": 1}])
|
||||
|
||||
self.assertTrue(frappe.db.exists("Batch", batch2))
|
||||
self.assertTrue(frappe.db.exists("Batch", {"item": item, "batch_id": batch2}))
|
||||
self.assertEqual(summary.total_qty, 5)
|
||||
|
||||
def test_update_batch_no_of_existing_entry(self):
|
||||
@@ -164,7 +178,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
bundle = summary.bundle
|
||||
pr.items[0].serial_and_batch_bundle = bundle
|
||||
pr.items[0].db_set("serial_and_batch_bundle", bundle)
|
||||
@@ -184,12 +198,12 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item)
|
||||
victim_pr = self.make_draft_pr(item)
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}])
|
||||
bundle = summary.bundle
|
||||
pr.items[0].db_set("serial_and_batch_bundle", bundle)
|
||||
|
||||
victim_summary = self.upsert(
|
||||
victim_pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}]
|
||||
victim_pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}]
|
||||
)
|
||||
victim_bundle = victim_summary.bundle
|
||||
victim_pr.items[0].db_set("serial_and_batch_bundle", victim_bundle)
|
||||
@@ -216,7 +230,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item, qty=5)
|
||||
serials = sorted(f"SN-{frappe.generate_hash(length=8)}" for _ in range(5))
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
|
||||
page = get_bundle_entries(summary.bundle, start=0, page_length=2)
|
||||
self.assertEqual(len(page["entries"]), 2)
|
||||
@@ -231,22 +245,22 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
token = frappe.generate_hash(length=8)
|
||||
serials = [f"AAA-{token}", f"BBB-{token}"]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
|
||||
page = get_bundle_entries(summary.bundle, search=f"AAA-{token}")
|
||||
self.assertEqual(len(page["entries"]), 1)
|
||||
self.assertEqual(page["entries"][0].serial_no, f"AAA-{token}")
|
||||
self.assertEqual(page["entries"][0].serial_number, f"AAA-{token}")
|
||||
|
||||
def test_rejected_bundle_created_separately(self):
|
||||
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
|
||||
pr = self.make_draft_pr(item)
|
||||
pr.items[0].rejected_warehouse = "_Test Warehouse 1 - _TC"
|
||||
|
||||
accepted = self.upsert(pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}])
|
||||
accepted = self.upsert(pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}])
|
||||
pr.items[0].serial_and_batch_bundle = accepted.bundle
|
||||
|
||||
rejected = self.upsert(
|
||||
pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}], is_rejected=1
|
||||
pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}], is_rejected=1
|
||||
)
|
||||
|
||||
self.assertNotEqual(accepted.bundle, rejected.bundle)
|
||||
@@ -260,21 +274,24 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
old_serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
new_serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(3)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in old_serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in old_serials])
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in new_serials], replace=1)
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in new_serials], replace=1)
|
||||
|
||||
self.assertEqual(summary.total_count, 3)
|
||||
remaining = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="serial_no")
|
||||
self.assertEqual(sorted(remaining), sorted(new_serials))
|
||||
self.assertEqual(
|
||||
sorted(frappe.get_all("Serial No", filters={"name": ("in", remaining)}, pluck="serial_no")),
|
||||
sorted(new_serials),
|
||||
)
|
||||
|
||||
def test_replace_with_no_entries_removes_bundle(self):
|
||||
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
|
||||
pr = self.make_draft_pr(item)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
bundle = summary.bundle
|
||||
pr.items[0].serial_and_batch_bundle = bundle
|
||||
|
||||
@@ -295,7 +312,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
summary = upsert_bundle_entries(
|
||||
child_row=json.dumps(child_row, default=str),
|
||||
doc=json.dumps(se.as_dict(), default=str),
|
||||
entries=json.dumps([{"serial_no": f"SN-{frappe.generate_hash(length=8)}"} for _ in range(2)]),
|
||||
entries=json.dumps([{"serial_number": f"SN-{frappe.generate_hash(length=8)}"} for _ in range(2)]),
|
||||
deleted=json.dumps([]),
|
||||
)
|
||||
|
||||
@@ -323,7 +340,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
upsert_bundle_entries,
|
||||
child_row=json.dumps(child_row, default=str),
|
||||
doc=json.dumps(pr.as_dict(), default=str),
|
||||
entries=json.dumps([{"serial_no": "SBIE-PT-0001"}]),
|
||||
entries=json.dumps([{"serial_number": "SBIE-PT-0001"}]),
|
||||
)
|
||||
|
||||
def test_upsert_rejects_unsupported_voucher_type(self):
|
||||
@@ -342,5 +359,5 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
upsert_bundle_entries,
|
||||
child_row=json.dumps(child_row, default=str),
|
||||
doc=json.dumps(doc, default=str),
|
||||
entries=json.dumps([{"serial_no": "SBIE-PT-0002"}]),
|
||||
entries=json.dumps([{"serial_number": "SBIE-PT-0002"}]),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
|
||||
# Explicit names below model historical records referenced by legacy ledgers.
|
||||
from frappe.utils import add_days, add_to_date, flt, nowtime, today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -46,7 +48,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": serial_item_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=sn)
|
||||
|
||||
bundle_doc = make_serial_batch_bundle(
|
||||
{
|
||||
@@ -252,7 +254,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_id)
|
||||
|
||||
self.assertTrue(batch_doc.use_batchwise_valuation)
|
||||
batch_doc.db_set(
|
||||
@@ -422,7 +424,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_id)
|
||||
|
||||
self.assertTrue(batch_doc.use_batchwise_valuation)
|
||||
batch_doc.db_set(
|
||||
@@ -550,7 +552,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": serial_no_item_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=serial_no_id)
|
||||
|
||||
sn_doc.db_set(
|
||||
{
|
||||
@@ -685,7 +687,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": serial_and_batch_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=serial_no)
|
||||
|
||||
bundle_doc = make_serial_batch_bundle(
|
||||
{
|
||||
@@ -741,7 +743,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": item,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=serial_no)
|
||||
|
||||
item_row = pr.items[0]
|
||||
item_row.type_of_transaction = "Inward"
|
||||
@@ -840,35 +842,37 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
item_code = make_item(properties={"has_batch_no": 1}).name
|
||||
|
||||
batch_id = "TEST-BATTCCH-VAL-00001"
|
||||
batch_nos = [{"batch_no": batch_id, "qty": 1}]
|
||||
batch_nos = [{"batch_number": batch_id, "qty": 1}]
|
||||
|
||||
make_batch_nos(item_code, batch_nos)
|
||||
self.assertTrue(frappe.db.exists("Batch", batch_id))
|
||||
use_batchwise_valuation = frappe.db.get_value("Batch", batch_id, "use_batchwise_valuation")
|
||||
self.assertTrue(frappe.db.exists("Batch", {"item": item_code, "batch_id": batch_id}))
|
||||
use_batchwise_valuation = frappe.db.get_value(
|
||||
"Batch", {"item": item_code, "batch_id": batch_id}, "use_batchwise_valuation"
|
||||
)
|
||||
self.assertEqual(use_batchwise_valuation, 1)
|
||||
|
||||
batch_id = "TEST-BATTCCH-VAL-00001"
|
||||
batch_nos = [{"batch_no": batch_id, "qty": 1}]
|
||||
batch_nos = [{"batch_number": batch_id, "qty": 1}]
|
||||
|
||||
# Shouldn't throw duplicate entry error
|
||||
make_batch_nos(item_code, batch_nos)
|
||||
self.assertTrue(frappe.db.exists("Batch", batch_id))
|
||||
self.assertTrue(frappe.db.exists("Batch", {"item": item_code, "batch_id": batch_id}))
|
||||
|
||||
def test_serial_no_duplicate_entry(self):
|
||||
item_code = make_item(properties={"has_serial_no": 1}).name
|
||||
|
||||
serial_no_id = "TEST-SNID-VAL-00001"
|
||||
serial_nos = [{"serial_no": serial_no_id, "qty": 1}]
|
||||
serial_nos = [{"serial_number": serial_no_id, "qty": 1}]
|
||||
|
||||
make_serial_nos(item_code, serial_nos)
|
||||
self.assertTrue(frappe.db.exists("Serial No", serial_no_id))
|
||||
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item_code, "serial_no": serial_no_id}))
|
||||
|
||||
serial_no_id = "TEST-SNID-VAL-00001"
|
||||
serial_nos = [{"batch_no": serial_no_id, "qty": 1}]
|
||||
serial_nos = [{"serial_number": serial_no_id, "qty": 1}]
|
||||
|
||||
# Shouldn't throw duplicate entry error
|
||||
make_serial_nos(item_code, serial_nos)
|
||||
self.assertTrue(frappe.db.exists("Serial No", serial_no_id))
|
||||
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item_code, "serial_no": serial_no_id}))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1}
|
||||
@@ -879,10 +883,10 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
item_code = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
|
||||
|
||||
serial_no = f"{item_code}-001"
|
||||
serial_nos = [{"serial_no": serial_no, "qty": 1}]
|
||||
serial_nos = [{"serial_number": serial_no, "qty": 1}]
|
||||
make_serial_nos(item_code, serial_nos)
|
||||
|
||||
pr1 = make_purchase_receipt(item=item_code, qty=1, rate=500, serial_no=[serial_no])
|
||||
pr1 = make_purchase_receipt(item=item_code, qty=1, rate=500, serial_no=[serial_nos[0]["serial_no"]])
|
||||
pr2 = make_purchase_receipt(item=item_code, qty=1, rate=500, do_not_save=True)
|
||||
|
||||
pr1.reload()
|
||||
@@ -906,7 +910,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": sn_item,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=serial_no)
|
||||
serial_nos.append(serial_no)
|
||||
|
||||
frappe.flags.ignore_serial_batch_bundle_validation = True
|
||||
@@ -1069,7 +1073,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": item_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name="ACSBBO-TACSB-00001")
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
@@ -1130,7 +1134,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": item_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name="TST-ACSBBO-TACSB-00001")
|
||||
|
||||
bundle_doc = make_serial_batch_bundle(
|
||||
{
|
||||
@@ -1233,7 +1237,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_id)
|
||||
|
||||
batch_doc.db_set(
|
||||
{
|
||||
@@ -1322,7 +1326,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_id)
|
||||
|
||||
batch_doc.db_set(
|
||||
{
|
||||
@@ -1391,7 +1395,9 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
|
||||
make_item(item_code, props)
|
||||
if batch_no and not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert()
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
set_name=batch_no
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True
|
||||
@@ -1475,7 +1481,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{"doctype": "Batch", "batch_id": batch_no, "item": item_code, "company": "_Test Company"}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_no)
|
||||
|
||||
def _allow_negative_stock_temporarily(self):
|
||||
for field in ("allow_negative_stock", "allow_negative_stock_for_batch"):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"autoname": "field:serial_no",
|
||||
"autoname": "hash",
|
||||
"creation": "2013-05-16 10:59:15",
|
||||
"description": "Distinct unit of an Item",
|
||||
"doctype": "DocType",
|
||||
@@ -58,12 +58,14 @@
|
||||
{
|
||||
"fieldname": "serial_no",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Serial No",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "serial_no",
|
||||
"oldfieldtype": "Data",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
"search_index": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "item_code",
|
||||
@@ -312,11 +314,11 @@
|
||||
"icon": "fa fa-barcode",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:48.936205",
|
||||
"modified": "2026-09-09 10:32:46.103583",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Serial No",
|
||||
"naming_rule": "By fieldname",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -367,10 +369,12 @@
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "item_code",
|
||||
"search_fields": "serial_no,item_code",
|
||||
"show_name_in_global_search": 1,
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "serial_no",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.query_builder.functions import Coalesce
|
||||
from frappe.utils import cint, cstr, getdate, nowdate, safe_json_loads
|
||||
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
|
||||
class SerialNoCannotCreateDirectError(ValidationError):
|
||||
@@ -65,6 +66,7 @@ class SerialNo(StockController):
|
||||
self.via_stock_ledger = False
|
||||
|
||||
def validate(self):
|
||||
SerialBatchIdentity("Serial No").validate(self)
|
||||
if self.get("__islocal") and self.warehouse and not self.via_stock_ledger:
|
||||
frappe.throw(
|
||||
_(
|
||||
@@ -110,7 +112,7 @@ class SerialNo(StockController):
|
||||
# Find the exact match
|
||||
sle_exists = False
|
||||
for d in sl_entries:
|
||||
if self.name.upper() in get_serial_nos(d.serial_no):
|
||||
if self.name in get_serial_nos(d.serial_no):
|
||||
sle_exists = True
|
||||
break
|
||||
|
||||
@@ -120,23 +122,27 @@ class SerialNo(StockController):
|
||||
)
|
||||
|
||||
|
||||
def get_available_serial_nos(serial_no_series, qty) -> list[str]:
|
||||
def get_available_serial_nos(serial_no_series, qty, item_code=None) -> list[str]:
|
||||
serial_nos = []
|
||||
for _i in range(cint(qty)):
|
||||
serial_nos.append(get_new_serial_number(serial_no_series))
|
||||
serial_nos.append(get_new_serial_number(serial_no_series, item_code))
|
||||
|
||||
return serial_nos
|
||||
|
||||
|
||||
def get_new_serial_number(series):
|
||||
def get_new_serial_number(series, item_code=None):
|
||||
sr_no = make_autoname(series, "Serial No")
|
||||
if frappe.db.exists("Serial No", sr_no):
|
||||
sr_no = get_new_serial_number(series)
|
||||
if SerialBatchIdentity("Serial No").exists(sr_no, item_code):
|
||||
sr_no = get_new_serial_number(series, item_code)
|
||||
return sr_no
|
||||
|
||||
|
||||
def get_items_html(serial_nos, item_code):
|
||||
body = ", ".join(serial_nos)
|
||||
from frappe.utils import escape_html
|
||||
|
||||
labels = SerialBatchIdentity("Serial No").labels(serial_nos)
|
||||
body = ", ".join(escape_html(labels.get(name, name)) for name in serial_nos)
|
||||
item_code = escape_html(item_code)
|
||||
return f"""<details><summary>
|
||||
<b>{item_code}:</b> {len(serial_nos)} Serial Numbers <span class="caret"></span>
|
||||
</summary>
|
||||
@@ -306,4 +312,5 @@ def get_serial_nos_for_outward(kwargs):
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
SerialBatchIdentity("Serial No").sync_constraint()
|
||||
frappe.db.add_index("Serial No", ["item_code", "warehouse"])
|
||||
|
||||
@@ -202,7 +202,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item_code, to_warehouse=warehouse, qty=1, rate=42, serial_no=[serial_nos[0]]
|
||||
@@ -350,7 +350,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"warranty_expiry_date": past_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCWARREXP" + random_string(6))
|
||||
frappe.db.set_value("Serial No", expired_sr.name, "maintenance_status", "Under Warranty")
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", expired_sr.name, "maintenance_status"), "Under Warranty"
|
||||
@@ -365,7 +365,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"warranty_expiry_date": future_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCWARRACT" + random_string(6))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", active_sr.name, "maintenance_status"), "Under Warranty"
|
||||
)
|
||||
@@ -402,7 +402,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"amc_expiry_date": past_date,
|
||||
"warranty_expiry_date": future_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCAMCEXCL" + random_string(6))
|
||||
frappe.db.set_value("Serial No", excluded_sr.name, "maintenance_status", "Out of AMC")
|
||||
|
||||
# Negative control: same lapsed amc date, but a status NOT in the excluded list, so it
|
||||
@@ -416,7 +416,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"amc_expiry_date": past_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCAMCCAND" + random_string(6))
|
||||
frappe.db.set_value("Serial No", candidate_sr.name, "maintenance_status", "Under AMC")
|
||||
|
||||
update_maintenance_status()
|
||||
@@ -446,7 +446,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"amc_expiry_date": past_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCAMCNULL" + random_string(6))
|
||||
# Force a NULL maintenance_status while a lapsed amc date keeps the row in or_filters.
|
||||
frappe.db.set_value("Serial No", null_sr.name, "maintenance_status", None)
|
||||
self.assertIsNone(frappe.db.get_value("Serial No", null_sr.name, "maintenance_status"))
|
||||
|
||||
@@ -910,7 +910,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
doc.serial_no = serial_no
|
||||
doc.item_code = "_Test Serialized Item"
|
||||
doc.company = "_Test Company"
|
||||
doc.insert(ignore_permissions=True)
|
||||
doc.insert(ignore_permissions=True, set_name=serial_no)
|
||||
|
||||
se = frappe.copy_doc(self.globalTestRecords["Stock Entry"][0])
|
||||
se.get("items")[0].item_code = "_Test Serialized Item"
|
||||
@@ -2831,6 +2831,9 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
"Test Use Serial and Batch Item SN Item - SN 001",
|
||||
"Test Use Serial and Batch Item SN Item - SN 002",
|
||||
]
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(item.name, serial_nos, create=True)
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item.name,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import (
|
||||
flt,
|
||||
get_link_to_form,
|
||||
@@ -13,10 +12,11 @@ from frappe.utils import (
|
||||
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
|
||||
OpeningEntryAccountError,
|
||||
)
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
|
||||
|
||||
class StockEntryDetail(Document):
|
||||
class StockEntryDetail(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from datetime import date
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.role.role import get_users
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Concat_ws, Max, Sum
|
||||
from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
|
||||
|
||||
@@ -17,6 +16,7 @@ from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_parsed_serial_nos
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchBundle, get_serial_nos
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference, format_serial_batch_numbers
|
||||
|
||||
|
||||
class StockFreezeError(frappe.ValidationError):
|
||||
@@ -38,7 +38,7 @@ class SerialNoInventoryDimensionError(frappe.ValidationError):
|
||||
exclude_from_linked_with = True
|
||||
|
||||
|
||||
class StockLedgerEntry(Document):
|
||||
class StockLedgerEntry(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
@@ -210,7 +210,8 @@ class StockLedgerEntry(Document):
|
||||
if mismatches:
|
||||
frappe.throw(
|
||||
_("Serial No {0} is not available in the selected inventory dimensions: {1}").format(
|
||||
frappe.bold(serial_no), frappe.bold(", ".join(mismatches))
|
||||
frappe.bold(format_serial_batch_numbers("Serial No", [serial_no])),
|
||||
frappe.bold(", ".join(mismatches)),
|
||||
),
|
||||
title=_("Incorrect Inventory Dimension"),
|
||||
exc=SerialNoInventoryDimensionError,
|
||||
@@ -383,7 +384,9 @@ class StockLedgerEntry(Document):
|
||||
if expiry_date:
|
||||
if getdate(self.posting_date) > getdate(expiry_date):
|
||||
frappe.throw(
|
||||
_("Batch {0} of Item {1} has expired.").format(self.batch_no, self.item_code)
|
||||
_("Batch {0} of Item {1} has expired.").format(
|
||||
format_serial_batch_numbers("Batch", [self.batch_no]), self.item_code
|
||||
)
|
||||
)
|
||||
|
||||
def validate_and_set_fiscal_year(self):
|
||||
|
||||
@@ -24,6 +24,7 @@ from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import BackDate
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
from erpnext.stock.tests.test_utils import StockTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -59,11 +60,9 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin):
|
||||
item = "_Test Serialized Item"
|
||||
serial = "_Test SN Tie 9"
|
||||
company_a, company_b = "_Test Company", "_Test Company 1"
|
||||
if frappe.db.exists("Serial No", serial):
|
||||
frappe.delete_doc("Serial No", serial, force=1)
|
||||
frappe.get_doc(
|
||||
{"doctype": "Serial No", "serial_no": serial, "item_code": item, "company": company_b}
|
||||
).insert(ignore_permissions=True)
|
||||
serial = SerialBatchIdentity("Serial No").resolve(
|
||||
item, [serial], create=True, defaults={"company": company_b}
|
||||
)[0]
|
||||
|
||||
def mk_sle(name, rate):
|
||||
if frappe.db.exists("Stock Ledger Entry", name):
|
||||
@@ -1691,7 +1690,8 @@ def setup_item_valuation_test(
|
||||
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):
|
||||
batches[i] = frappe.db.get_value("Batch", {"item": item.item_code, "batch_id": batch_id})
|
||||
if not batches[i]:
|
||||
ubw = use_batchwise_valuation
|
||||
if isinstance(use_batchwise_valuation, list | tuple):
|
||||
ubw = use_batchwise_valuation[i]
|
||||
@@ -1702,6 +1702,7 @@ def setup_item_valuation_test(
|
||||
).insert()
|
||||
batch.use_batchwise_valuation = ubw
|
||||
batch.db_update()
|
||||
batches[i] = batch.name
|
||||
|
||||
return item.item_code, warehouses, batches
|
||||
|
||||
|
||||
@@ -549,7 +549,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
def test_valid_batch(self):
|
||||
create_batch_item_with_batch("Testing Batch Item 1", "001")
|
||||
create_batch_item_with_batch("Testing Batch Item 2", "002")
|
||||
batch_no = create_batch_item_with_batch("Testing Batch Item 2", "002")
|
||||
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
@@ -559,7 +559,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
"voucher_type": "Stock Reconciliation",
|
||||
"entries": [
|
||||
{
|
||||
"batch_no": "002",
|
||||
"batch_no": batch_no,
|
||||
"qty": 1,
|
||||
"incoming_rate": 100,
|
||||
}
|
||||
@@ -567,7 +567,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not belong to Item", doc.save)
|
||||
|
||||
def test_serial_no_cancellation(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
@@ -615,7 +615,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
"serial_no": "SR-CREATED-SR-NO",
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="SR-CREATED-SR-NO")
|
||||
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item.name,
|
||||
@@ -1351,7 +1351,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(set_name=batch_id, ignore_permissions=True)
|
||||
|
||||
self.assertTrue(batch_doc.use_batchwise_valuation)
|
||||
|
||||
@@ -2219,17 +2219,15 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
|
||||
def create_batch_item_with_batch(item_name, batch_id):
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
batch_item_doc = create_item(item_name, is_stock_item=1)
|
||||
if not batch_item_doc.has_batch_no:
|
||||
batch_item_doc.has_batch_no = 1
|
||||
batch_item_doc.create_new_batch = 1
|
||||
batch_item_doc.save(ignore_permissions=True)
|
||||
|
||||
if not frappe.db.exists("Batch", batch_id):
|
||||
b = frappe.new_doc("Batch")
|
||||
b.item = item_name
|
||||
b.batch_id = batch_id
|
||||
b.save()
|
||||
return SerialBatchIdentity("Batch").resolve(item_name, [batch_id], create=True)[0]
|
||||
|
||||
|
||||
def insert_existing_sle(warehouse, item_code="_Test Item"):
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class StockReconciliationItem(Document):
|
||||
class StockReconciliationItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, get_datetime, today
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
columns, data = [], []
|
||||
data = get_data(filters)
|
||||
|
||||
@@ -11,9 +11,11 @@ from erpnext.stock.report.stock_ledger.stock_ledger import (
|
||||
get_opening_balance,
|
||||
get_stock_ledger_entries,
|
||||
)
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
from erpnext.stock.utils import is_reposting_item_valuation_in_progress
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
is_reposting_item_valuation_in_progress()
|
||||
columns = get_columns(filters)
|
||||
|
||||
@@ -6,7 +6,10 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Date
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
validate_filters(filters)
|
||||
|
||||
|
||||
@@ -46,12 +46,13 @@ class TestBatchItemExpiryStatus(ERPNextTestSuite):
|
||||
|
||||
data = self.run_report(item=item)
|
||||
|
||||
# Columns: [item, item_name, batch, stock_uom, quantity, expires_on, expiry_in_days]
|
||||
row = next((r for r in data if r[2] == batch_no), None)
|
||||
# Physical batch numbers are displayed; the final hidden column retains the ID.
|
||||
row = next((r for r in data if r[-1] == batch_no), None)
|
||||
self.assertIsNotNone(row, f"Batch {batch_no} not found in report for item {item}")
|
||||
|
||||
self.assertEqual(row[0], item)
|
||||
self.assertEqual(row[2], batch_no)
|
||||
self.assertEqual(row[2], frappe.db.get_value("Batch", batch_no, "batch_id"))
|
||||
self.assertEqual(row[-1], batch_no)
|
||||
self.assertEqual(row[4], 10)
|
||||
# expiry = batch manufacturing_date + 30 day shelf life; matches the Batch record
|
||||
batch_expiry = frappe.db.get_value("Batch", batch_no, "expiry_date")
|
||||
|
||||
@@ -3,7 +3,10 @@ from collections import defaultdict
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
filters = frappe._dict(filters or {})
|
||||
return get_columns(), get_data(filters)
|
||||
|
||||
@@ -11,10 +11,12 @@ from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
from erpnext.deprecation_dumpster import deprecated
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import StockClosing
|
||||
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
SLE_COUNT_LIMIT = 100_000
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
@@ -8,6 +8,8 @@ from frappe import _
|
||||
from frappe.utils import flt
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
SLE_FIELDS = (
|
||||
"name",
|
||||
"item_code",
|
||||
@@ -26,6 +28,7 @@ SLE_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
columns = get_columns()
|
||||
data = get_data(filters)
|
||||
|
||||
@@ -7,8 +7,10 @@ import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
columns, data = [], []
|
||||
columns = get_columns()
|
||||
|
||||
@@ -6,8 +6,10 @@ from frappe import _
|
||||
from frappe.utils import add_to_date, flt, today
|
||||
|
||||
from erpnext.stock.report.stock_ledger.stock_ledger import execute as stock_ledger_execute
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters: dict | None = None):
|
||||
"""Return columns and data for the report.
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ from typing import Any
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
data = get_data(filters)
|
||||
columns = get_columns(filters, data)
|
||||
@@ -199,57 +203,35 @@ def get_voucher_type(doctype: Any, txt: str, searchfield: Any, start: int, page_
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_serial_nos(doctype: Any, txt: str, searchfield: Any, start: int, page_len: int, filters: dict):
|
||||
query_filters = {}
|
||||
|
||||
if txt:
|
||||
query_filters["serial_no"] = ["like", f"%{txt}%"]
|
||||
|
||||
if filters.get("voucher_no"):
|
||||
serial_batch_bundle = frappe.get_cached_value(
|
||||
"Serial and Batch Bundle",
|
||||
{"voucher_no": ("in", filters.get("voucher_no")), "docstatus": 1, "is_cancelled": 0},
|
||||
"name",
|
||||
)
|
||||
|
||||
query_filters["parent"] = serial_batch_bundle
|
||||
if not txt:
|
||||
query_filters["serial_no"] = ("is", "set")
|
||||
|
||||
return frappe.get_all(
|
||||
"Serial and Batch Entry", filters=query_filters, fields=["serial_no"], as_list=True
|
||||
)
|
||||
|
||||
else:
|
||||
query_filters["item_code"] = filters.get("item_code")
|
||||
return frappe.get_all("Serial No", filters=query_filters, as_list=True)
|
||||
return get_number_options("Serial No", txt, start, page_len, filters)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_batch_nos(doctype: Any, txt: str, searchfield: Any, start: int, page_len: int, filters: dict):
|
||||
return get_number_options("Batch", txt, start, page_len, filters)
|
||||
|
||||
|
||||
def get_number_options(doctype, txt, start, page_len, filters):
|
||||
identity = SerialBatchIdentity(doctype)
|
||||
query_filters = {}
|
||||
|
||||
if filters.get("voucher_no") and txt:
|
||||
query_filters["batch_no"] = ["like", f"%{txt}%"]
|
||||
|
||||
if filters.get("item_code"):
|
||||
query_filters[identity.item_field] = filters["item_code"]
|
||||
if filters.get("voucher_no"):
|
||||
serial_batch_bundle = frappe.get_cached_value(
|
||||
bundles = frappe.get_all(
|
||||
"Serial and Batch Bundle",
|
||||
{"voucher_no": ("in", filters.get("voucher_no")), "docstatus": 1, "is_cancelled": 0},
|
||||
"name",
|
||||
filters={"voucher_no": ("in", filters["voucher_no"]), "docstatus": 1, "is_cancelled": 0},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
query_filters["parent"] = serial_batch_bundle
|
||||
if not txt:
|
||||
query_filters["batch_no"] = ("is", "set")
|
||||
|
||||
return frappe.get_all(
|
||||
"Serial and Batch Entry", filters=query_filters, fields=["batch_no"], as_list=True
|
||||
)
|
||||
|
||||
else:
|
||||
if txt:
|
||||
query_filters["name"] = ["like", f"%{txt}%"]
|
||||
|
||||
query_filters["item"] = filters.get("item_code")
|
||||
return frappe.get_all("Batch", filters=query_filters, as_list=True)
|
||||
link = "serial_no" if doctype == "Serial No" else "batch_no"
|
||||
ids = frappe.get_all("Serial and Batch Entry", filters={"parent": ("in", bundles)}, pluck=link)
|
||||
query_filters["name"] = ("in", [name for name in ids if name])
|
||||
return frappe.get_list(
|
||||
doctype,
|
||||
filters=query_filters,
|
||||
or_filters={identity.number_field: ("like", f"%{txt}%"), "name": txt},
|
||||
fields=["name", identity.number_field],
|
||||
start=start,
|
||||
page_length=page_len,
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ frappe.query_reports["Serial No and Batch Traceability"] = {
|
||||
},
|
||||
};
|
||||
|
||||
function getTraceabilityLink({ type, value, original_value, item_code, data, filter_values }) {
|
||||
function getTraceabilityLink({ type, value, original_value, number, item_code, data, filter_values }) {
|
||||
if (!value) return value;
|
||||
|
||||
const link_doctype = type === "batch_no" ? "Batch" : "Serial No";
|
||||
@@ -87,7 +87,7 @@ function getTraceabilityLink({ type, value, original_value, item_code, data, fil
|
||||
return `<a class="${css_class}" href="${frappe.utils.get_form_link(
|
||||
link_doctype,
|
||||
original_value
|
||||
)}">${frappe.utils.escape_html(original_value)}</a>`;
|
||||
)}">${frappe.utils.escape_html(number)}</a>`;
|
||||
}
|
||||
|
||||
function custom_formatter(value, row, column, data, default_formatter) {
|
||||
@@ -100,11 +100,12 @@ function custom_formatter(value, row, column, data, default_formatter) {
|
||||
|
||||
value = default_formatter(value, row, column, data);
|
||||
|
||||
if (["batch_no", "serial_no"].includes(column.fieldname) && value) {
|
||||
if (["batch_no_number", "serial_no_number"].includes(column.fieldname) && value) {
|
||||
value = getTraceabilityLink({
|
||||
type: column.fieldname,
|
||||
type: column.reference_field,
|
||||
value,
|
||||
original_value,
|
||||
original_value: data[column.reference_field],
|
||||
number: original_value,
|
||||
item_code,
|
||||
data,
|
||||
filter_values,
|
||||
|
||||
@@ -5,7 +5,10 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters: dict | None = None):
|
||||
report = ReportData(filters)
|
||||
report.validate_filters()
|
||||
|
||||
@@ -8,12 +8,14 @@ from frappe import _
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_serial_nos_from_sle
|
||||
from erpnext.stock.serial_batch_bundle import get_serial_no_status
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
from erpnext.stock.stock_ledger import get_stock_ledger_entries
|
||||
|
||||
BUYING_VOUCHER_TYPES = ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]
|
||||
SELLING_VOUCHER_TYPES = ["Sales Invoice", "Delivery Note"]
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
columns = get_columns(filters)
|
||||
data = get_data(filters)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.modified\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [[\"Serial No\", \"warehouse\", \"=\", \"\"]], \"sort_order_next\": \"desc\", \"columns\": [[\"name\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"amc_expiry_date\", \"Serial No\"], [\"maintenance_status\", \"Serial No\"],[\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"]]}",
|
||||
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.modified\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [[\"Serial No\", \"warehouse\", \"=\", \"\"]], \"sort_order_next\": \"desc\", \"columns\": [[\"serial_no\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"amc_expiry_date\", \"Serial No\"], [\"maintenance_status\", \"Serial No\"],[\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"]]}",
|
||||
"letterhead": null,
|
||||
"modified": "2024-09-26 13:07:23.451182",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"filters": [],
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.name\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [], \"sort_order_next\": \"desc\", \"columns\": [[\"name\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"warehouse\", \"Serial No\"], [\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"],[\"purchase_document_no\", \"Serial No\"]]}",
|
||||
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.serial_no\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [], \"sort_order_next\": \"desc\", \"columns\": [[\"serial_no\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"warehouse\", \"Serial No\"], [\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"],[\"purchase_document_no\", \"Serial No\"]]}",
|
||||
"letterhead": null,
|
||||
"modified": "2024-09-26 13:10:52.693648",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.modified\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [[\"Serial No\", \"warehouse\", \"=\", \"\"]], \"sort_order_next\": \"desc\", \"columns\": [[\"name\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"amc_expiry_date\", \"Serial No\"], [\"maintenance_status\", \"Serial No\"],[\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"]]}",
|
||||
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.modified\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [[\"Serial No\", \"warehouse\", \"=\", \"\"]], \"sort_order_next\": \"desc\", \"columns\": [[\"serial_no\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"amc_expiry_date\", \"Serial No\"], [\"maintenance_status\", \"Serial No\"],[\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"]]}",
|
||||
"letterhead": null,
|
||||
"modified": "2025-04-24 13:07:23.451182",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -487,7 +487,8 @@ class FIFOSlots:
|
||||
or []
|
||||
)
|
||||
|
||||
return self.uppercase_serial_nos(serial_nos), batch_nos
|
||||
# FIFO slots use normalized comparison keys, not document links.
|
||||
return [name.upper() for name in serial_nos], batch_nos
|
||||
|
||||
def _get_row_batch_nos(self, row: dict) -> list:
|
||||
if not row.batch_no:
|
||||
@@ -512,10 +513,6 @@ class FIFOSlots:
|
||||
elif len(fifo_queue) > qty_after:
|
||||
fifo_queue[:] = fifo_queue[:qty_after]
|
||||
|
||||
def uppercase_serial_nos(self, serial_nos):
|
||||
"Convert serial nos to uppercase for uniformity."
|
||||
return [sn.upper() for sn in serial_nos]
|
||||
|
||||
def _get_batchwise_valuation(self, batch_no: str):
|
||||
if batch_no not in self.batchwise_valuation_by_batch:
|
||||
# only reachable when stock ledger entries are passed in directly;
|
||||
|
||||
@@ -12,6 +12,7 @@ from erpnext.stock.report.stock_ageing.stock_ageing import (
|
||||
format_report_data,
|
||||
get_average_age,
|
||||
)
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -19,6 +20,46 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
def setUp(self) -> None:
|
||||
self.filters = frappe._dict(company="_Test Company", to_date="2021-12-10", ranges=["30", "60", "90"])
|
||||
|
||||
def test_legacy_serial_references_match_regardless_of_case(self):
|
||||
rows = [
|
||||
frappe._dict(
|
||||
name="Serialized Item",
|
||||
actual_qty=qty,
|
||||
qty_after_transaction=balance,
|
||||
stock_value_difference=qty * 10,
|
||||
warehouse="WH 1",
|
||||
posting_date=date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=str(index),
|
||||
has_serial_no=True,
|
||||
serial_no=serials,
|
||||
)
|
||||
for index, (qty, balance, date, serials) in enumerate(
|
||||
[(2, 2, "2021-12-01", "id-aB\nid-Cd"), (-1, 1, "2021-12-02", "ID-Ab")]
|
||||
)
|
||||
]
|
||||
slots = FIFOSlots(self.filters, rows).generate()
|
||||
self.assertEqual(slots["Serialized Item"]["fifo_queue"], [["ID-CD", "2021-12-01", 10.0]])
|
||||
self.assertEqual([row.serial_no for row in rows], ["id-aB\nid-Cd", "ID-Ab"])
|
||||
|
||||
def test_fifo_normalization_preserves_ids_for_database_lookups(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item(properties={"has_batch_no": 1})
|
||||
batch = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"item": item.name,
|
||||
"batch_id": "Physical-Batch",
|
||||
"use_batchwise_valuation": 1,
|
||||
}
|
||||
).insert(set_name="Mixed-Case-" + frappe.generate_hash(length=10))
|
||||
row = frappe._dict(batch_no=batch.name, actual_qty=1, stock_value_difference=10)
|
||||
batches = FIFOSlots(self.filters, [])._get_row_batch_nos(row)
|
||||
self.assertEqual(batches, [[batch.name.upper(), 1, 1, 10]])
|
||||
self.assertEqual(row.batch_no, batch.name)
|
||||
self.assertEqual(frappe.get_doc("Batch", row.batch_no).batch_id, "Physical-Batch")
|
||||
|
||||
def test_normal_inward_outward_queue(self):
|
||||
"Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)"
|
||||
sle = [
|
||||
@@ -530,12 +571,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-RECO-REVALUE-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
batch_no = make_batch(item_code, "SA-RECO-REVALUE-BATCH")
|
||||
|
||||
def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
@@ -566,8 +602,8 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(
|
||||
queue,
|
||||
[
|
||||
[batch_no, 1, 10.0, "2021-12-01", 20.0],
|
||||
[batch_no, 1, 2.0, "2021-12-02", 4.0],
|
||||
[batch_no.upper(), 1, 10.0, "2021-12-01", 20.0],
|
||||
[batch_no.upper(), 1, 2.0, "2021-12-02", 4.0],
|
||||
],
|
||||
)
|
||||
|
||||
@@ -583,12 +619,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-PARTIAL-RECO-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
batch_no = make_batch(item_code, "SA-PARTIAL-RECO-BATCH")
|
||||
|
||||
def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
@@ -618,8 +649,8 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(
|
||||
[slot[:4] for slot in queue],
|
||||
[
|
||||
[batch_no, 1, 10.0, "2021-12-01"],
|
||||
[batch_no, 1, 2.0, "2021-12-01"],
|
||||
[batch_no.upper(), 1, 10.0, "2021-12-01"],
|
||||
[batch_no.upper(), 1, 2.0, "2021-12-01"],
|
||||
],
|
||||
)
|
||||
self.assertAlmostEqual(queue[0][4], 1166.67, places=2)
|
||||
@@ -636,12 +667,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-SPLIT-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
batch_no = make_batch(item_code, "SA-POOL-SPLIT-BATCH")
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
@@ -671,8 +697,8 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(
|
||||
queue,
|
||||
[
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
[batch_no.upper(), 1, 10.0, "2021-12-01", 50.0],
|
||||
[batch_no.upper(), 1, 10.0, "2021-12-01", 50.0],
|
||||
],
|
||||
)
|
||||
|
||||
@@ -687,12 +713,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-RESIDUAL-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
batch_no = make_batch(item_code, "SA-POOL-RESIDUAL-BATCH")
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
@@ -734,12 +755,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-REBALANCE-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
batch_no = make_batch(item_code, "SA-POOL-REBALANCE-BATCH")
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
@@ -770,8 +786,8 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(
|
||||
queue,
|
||||
[
|
||||
[batch_no, 1, 6.0, "2021-12-01", 30.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
[batch_no.upper(), 1, 6.0, "2021-12-01", 30.0],
|
||||
[batch_no.upper(), 1, 10.0, "2021-12-01", 50.0],
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1533,38 +1549,14 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
},
|
||||
).name
|
||||
|
||||
def make_batch(batch_id, use_batchwise_valuation):
|
||||
if not frappe.db.exists("Batch", batch_id):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_id,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", use_batchwise_valuation)
|
||||
|
||||
batchwise_above_90 = "SA-BATCHWISE-ABOVE-90"
|
||||
non_batchwise_above_90 = "SA-NON-BATCHWISE-ABOVE-90"
|
||||
batchwise_61_90 = "SA-BATCHWISE-61-90"
|
||||
non_batchwise_61_90 = "SA-NON-BATCHWISE-61-90"
|
||||
batchwise_31_60 = "SA-BATCHWISE-31-60"
|
||||
non_batchwise_31_60 = "SA-NON-BATCHWISE-31-60"
|
||||
batchwise_0_30 = "SA-BATCHWISE-0-30"
|
||||
non_batchwise_0_30 = "SA-NON-BATCHWISE-0-30"
|
||||
|
||||
for batch_id, use_batchwise_valuation in {
|
||||
batchwise_above_90: 1,
|
||||
non_batchwise_above_90: 0,
|
||||
batchwise_61_90: 1,
|
||||
non_batchwise_61_90: 0,
|
||||
batchwise_31_60: 1,
|
||||
non_batchwise_31_60: 0,
|
||||
batchwise_0_30: 1,
|
||||
non_batchwise_0_30: 0,
|
||||
}.items():
|
||||
make_batch(batch_id, use_batchwise_valuation)
|
||||
batchwise_above_90 = make_batch(item_code, "SA-BATCHWISE-ABOVE-90", 1)
|
||||
non_batchwise_above_90 = make_batch(item_code, "SA-NON-BATCHWISE-ABOVE-90", 0)
|
||||
batchwise_61_90 = make_batch(item_code, "SA-BATCHWISE-61-90", 1)
|
||||
non_batchwise_61_90 = make_batch(item_code, "SA-NON-BATCHWISE-61-90", 0)
|
||||
batchwise_31_60 = make_batch(item_code, "SA-BATCHWISE-31-60", 1)
|
||||
non_batchwise_31_60 = make_batch(item_code, "SA-NON-BATCHWISE-31-60", 0)
|
||||
batchwise_0_30 = make_batch(item_code, "SA-BATCHWISE-0-30", 1)
|
||||
non_batchwise_0_30 = make_batch(item_code, "SA-NON-BATCHWISE-0-30", 0)
|
||||
|
||||
qty_after_transaction = 0
|
||||
|
||||
@@ -1615,13 +1607,13 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"],
|
||||
[
|
||||
[batchwise_above_90, 1, 40.0, "2021-08-01", 400.0],
|
||||
[batchwise_61_90, 1, 35.0, "2021-09-20", 350.0],
|
||||
[non_batchwise_61_90, 0, 40.0, "2021-09-25", 400.0],
|
||||
[batchwise_31_60, 1, 22.0, "2021-10-20", 220.0],
|
||||
[non_batchwise_31_60, 0, 40, "2021-10-25", 400],
|
||||
[batchwise_0_30, 1, 14.0, "2021-11-20", 140.0],
|
||||
[non_batchwise_0_30, 0, 30, "2021-11-25", 300],
|
||||
[batchwise_above_90.upper(), 1, 40.0, "2021-08-01", 400.0],
|
||||
[batchwise_61_90.upper(), 1, 35.0, "2021-09-20", 350.0],
|
||||
[non_batchwise_61_90.upper(), 0, 40.0, "2021-09-25", 400.0],
|
||||
[batchwise_31_60.upper(), 1, 22.0, "2021-10-20", 220.0],
|
||||
[non_batchwise_31_60.upper(), 0, 40, "2021-10-25", 400],
|
||||
[batchwise_0_30.upper(), 1, 14.0, "2021-11-20", 140.0],
|
||||
[non_batchwise_0_30.upper(), 0, 30, "2021-11-25", 300],
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1641,22 +1633,8 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
},
|
||||
).name
|
||||
|
||||
def make_batch(batch_id):
|
||||
if not frappe.db.exists("Batch", batch_id):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_id,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", 1)
|
||||
|
||||
source_batch = "SA-BATCHWISE-TRANSFER-SOURCE"
|
||||
target_batch = "SA-BATCHWISE-TRANSFER-TARGET"
|
||||
make_batch(source_batch)
|
||||
make_batch(target_batch)
|
||||
source_batch = make_batch(item_code, "SA-BATCHWISE-TRANSFER-SOURCE")
|
||||
target_batch = make_batch(item_code, "SA-BATCHWISE-TRANSFER-TARGET")
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
@@ -1714,8 +1692,8 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"],
|
||||
[
|
||||
[source_batch, 1, 5.0, "2021-09-01", 50.0],
|
||||
[target_batch, 1, 10.0, "2021-09-01", 100.0],
|
||||
[source_batch.upper(), 1, 5.0, "2021-09-01", 50.0],
|
||||
[target_batch.upper(), 1, 10.0, "2021-09-01", 100.0],
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
@@ -1735,17 +1713,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
},
|
||||
).name
|
||||
|
||||
batch_no = "SA-BATCHWISE-NEGATIVE-STOCK"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
batch_no = make_batch(item_code, "SA-BATCHWISE-NEGATIVE-STOCK")
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
@@ -1769,7 +1737,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -10, "2021-12-01", -100]])
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no.upper(), 1, -10, "2021-12-01", -100]])
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("001", item_code, "WH 1")], [[10, "2021-12-01", 100]]
|
||||
)
|
||||
@@ -1796,7 +1764,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-12-01", -40.0]])
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no.upper(), 1, -4.0, "2021-12-01", -40.0]])
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("001", item_code, "WH 1")],
|
||||
[[4.0, "2021-12-01", 40.0]],
|
||||
@@ -1814,19 +1782,8 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
},
|
||||
).name
|
||||
|
||||
buffer_batch = "SA-BATCHWISE-NEGATIVE-BUFFER"
|
||||
negative_batch = "SA-BATCHWISE-NEGATIVE-NON-HEAD"
|
||||
for batch_no in [buffer_batch, negative_batch]:
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
buffer_batch = make_batch(item_code, "SA-BATCHWISE-NEGATIVE-BUFFER")
|
||||
negative_batch = make_batch(item_code, "SA-BATCHWISE-NEGATIVE-NON-HEAD")
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
@@ -1884,8 +1841,8 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"],
|
||||
[
|
||||
[buffer_batch, 1, 5, "2021-11-30", 50],
|
||||
[negative_batch, 1, -4.0, "2021-12-01", -40.0],
|
||||
[buffer_batch.upper(), 1, 5, "2021-11-30", 50],
|
||||
[negative_batch.upper(), 1, -4.0, "2021-12-01", -40.0],
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
@@ -1905,17 +1862,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
},
|
||||
).name
|
||||
|
||||
batch_no = "SA-BATCHWISE-NEGATIVE-LATER-VOUCHER"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
batch_no = make_batch(item_code, "SA-BATCHWISE-NEGATIVE-LATER-VOUCHER")
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
@@ -1955,7 +1902,7 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(item_result["total_qty"], -4.0)
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no.upper(), 1, -4.0, "2021-11-10", -40.0]])
|
||||
|
||||
def test_untagged_receipt_with_negative_batch_head(self):
|
||||
"""An incoming SLE without batch details must not treat a negative
|
||||
@@ -2143,3 +2090,9 @@ def generate_item_and_item_wh_wise_slots(filters, sle):
|
||||
filters.show_warehouse_wise_stock = False
|
||||
|
||||
return item_wise_slots, item_wh_wise_slots
|
||||
|
||||
|
||||
def make_batch(item_code, batch_id, use_batchwise_valuation=1):
|
||||
batch_no = SerialBatchIdentity("Batch").resolve(item_code, [batch_id], create=True)[0]
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", use_batchwise_valuation)
|
||||
return batch_no
|
||||
|
||||
@@ -15,12 +15,14 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_in
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import get_stock_balance_for
|
||||
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
from erpnext.stock.utils import (
|
||||
is_reposting_item_valuation_in_progress,
|
||||
update_included_uom_in_report,
|
||||
)
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
is_reposting_item_valuation_in_progress()
|
||||
include_uom = filters.get("include_uom")
|
||||
|
||||
@@ -7,6 +7,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt, get_link_to_form, parse_json
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
SLE_FIELDS = (
|
||||
@@ -31,6 +32,7 @@ SLE_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
columns = get_columns()
|
||||
data = get_data(filters)
|
||||
|
||||
@@ -10,8 +10,10 @@ from frappe.utils import cint, flt
|
||||
from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import (
|
||||
get_data as stock_ledger_invariant_check,
|
||||
)
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
columns, data = [], []
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
@@ -12,6 +12,7 @@ from erpnext.stock.deprecated_serial_batch import (
|
||||
DeprecatedBatchNoValuation,
|
||||
DeprecatedSerialNoValuation,
|
||||
)
|
||||
from erpnext.stock.serial_batch_display import format_serial_batch_numbers
|
||||
from erpnext.stock.valuation import round_off_if_near_zero
|
||||
|
||||
CONSUMED_SERIAL_NO_STOCK_ENTRY_PURPOSES = (
|
||||
@@ -605,6 +606,10 @@ def get_serial_nos_from_bundle(serial_and_batch_bundle, serial_nos=None):
|
||||
|
||||
|
||||
def get_serial_or_batch_nos(bundle):
|
||||
from frappe.utils import escape_html
|
||||
|
||||
from erpnext.stock.serial_batch_identity import add_number_labels
|
||||
|
||||
# For print format
|
||||
|
||||
bundle_data = frappe.get_cached_value(
|
||||
@@ -620,16 +625,18 @@ def get_serial_or_batch_nos(bundle):
|
||||
|
||||
data = frappe.get_all("Serial and Batch Entry", fields=fields, filters={"parent": bundle})
|
||||
|
||||
add_number_labels(data)
|
||||
|
||||
if bundle_data.has_serial_no and not bundle_data.has_batch_no:
|
||||
return ", ".join([d.serial_no for d in data])
|
||||
return ", ".join([escape_html(d.serial_number) for d in data])
|
||||
|
||||
elif bundle_data.has_batch_no:
|
||||
html = "<table class= 'table table-borderless' style='margin-top: 0px;margin-bottom: 0px;'>"
|
||||
for d in data:
|
||||
if d.serial_no:
|
||||
html += f"<tr><td>{d.batch_no}</td><td>{d.serial_no}</td><td>{abs(d.qty)}</td></tr>"
|
||||
html += f"<tr><td>{escape_html(d.batch_number or '')}</td><td>{escape_html(d.serial_number or '')}</td><td>{abs(d.qty)}</td></tr>"
|
||||
else:
|
||||
html += f"<tr><td>{d.batch_no}</td><td>{abs(d.qty)}</td></tr>"
|
||||
html += f"<tr><td>{escape_html(d.batch_number or '')}</td><td>{abs(d.qty)}</td></tr>"
|
||||
|
||||
html += "</table>"
|
||||
|
||||
@@ -1384,57 +1391,21 @@ class SerialBatchCreation:
|
||||
self.batches = frappe._dict({self.batch_no: abs(self.actual_qty)})
|
||||
|
||||
def make_serial_no_if_not_exists(self):
|
||||
non_exists_serial_nos = []
|
||||
for row in self.serial_nos:
|
||||
if not frappe.db.exists("Serial No", row):
|
||||
non_exists_serial_nos.append(row)
|
||||
|
||||
if non_exists_serial_nos:
|
||||
self.make_serial_nos(non_exists_serial_nos)
|
||||
|
||||
def make_serial_nos(self, serial_nos):
|
||||
serial_nos_details = []
|
||||
batch_no = None
|
||||
if self.batches:
|
||||
batch_no = next(iter(self.batches.keys()))
|
||||
|
||||
for serial_no in serial_nos:
|
||||
serial_nos_details.append(
|
||||
(
|
||||
serial_no,
|
||||
serial_no,
|
||||
now(),
|
||||
now(),
|
||||
frappe.session.user,
|
||||
frappe.session.user,
|
||||
self.warehouse,
|
||||
self.company,
|
||||
self.item_code,
|
||||
self.item_name,
|
||||
self.description,
|
||||
"Active",
|
||||
batch_no,
|
||||
)
|
||||
# Transaction fields contain IDs. Physical input is resolved during save.
|
||||
existing = set(
|
||||
frappe.get_all(
|
||||
"Serial No",
|
||||
filters={"name": ("in", self.serial_nos), "item_code": self.item_code},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
if serial_nos_details:
|
||||
fields = [
|
||||
"name",
|
||||
"serial_no",
|
||||
"creation",
|
||||
"modified",
|
||||
"owner",
|
||||
"modified_by",
|
||||
"warehouse",
|
||||
"company",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
"status",
|
||||
"batch_no",
|
||||
]
|
||||
|
||||
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
|
||||
)
|
||||
for name in self.serial_nos:
|
||||
if name not in existing:
|
||||
frappe.throw(
|
||||
_("Serial No {0} does not exist for Item {1}").format(
|
||||
format_serial_batch_numbers("Serial No", [name]), self.item_code
|
||||
)
|
||||
)
|
||||
|
||||
def set_serial_batch_entries(self, doc):
|
||||
incoming_rate = self.get("incoming_rate")
|
||||
@@ -1535,95 +1506,46 @@ class SerialBatchCreation:
|
||||
)
|
||||
|
||||
def get_auto_created_serial_nos(self):
|
||||
sr_nos = []
|
||||
serial_nos_details = []
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
if not self.serial_no_series:
|
||||
msg = f"Please set Serial No Series in the item {self.item_code} or create Serial and Batch Bundle manually."
|
||||
frappe.throw(_(msg))
|
||||
frappe.throw(_("Please set Serial No Series in Item {0}").format(self.item_code))
|
||||
|
||||
voucher_no = ""
|
||||
if self.get("voucher_no"):
|
||||
voucher_no = self.get("voucher_no")
|
||||
|
||||
voucher_type = ""
|
||||
if self.get("voucher_type"):
|
||||
voucher_type = self.get("voucher_type")
|
||||
|
||||
obj = NamingSeries(self.serial_no_series)
|
||||
current_value = obj.get_current_value()
|
||||
series = NamingSeries(self.serial_no_series)
|
||||
current_value = series.get_current_value()
|
||||
|
||||
def get_series(partial_series, digits):
|
||||
return f"{current_value:0{digits}d}"
|
||||
|
||||
posting_date = frappe.db.get_value(
|
||||
voucher_type,
|
||||
voucher_no,
|
||||
"posting_date",
|
||||
)
|
||||
|
||||
for _i in range(abs(cint(self.actual_qty))):
|
||||
numbers = []
|
||||
for _index in range(abs(cint(self.actual_qty))):
|
||||
current_value += 1
|
||||
serial_no = parse_naming_series(self.serial_no_series, number_generator=get_series)
|
||||
numbers.append(parse_naming_series(self.serial_no_series, number_generator=get_series))
|
||||
|
||||
sr_nos.append(serial_no)
|
||||
serial_nos_details.append(
|
||||
(
|
||||
serial_no,
|
||||
serial_no,
|
||||
now(),
|
||||
now(),
|
||||
frappe.session.user,
|
||||
frappe.session.user,
|
||||
self.warehouse,
|
||||
self.company,
|
||||
self.item_code,
|
||||
self.item_name,
|
||||
self.description,
|
||||
"Active",
|
||||
voucher_type,
|
||||
voucher_no,
|
||||
posting_date,
|
||||
self.batch_no,
|
||||
)
|
||||
try:
|
||||
ids = SerialBatchIdentity("Serial No").create_many(
|
||||
self.item_code,
|
||||
numbers,
|
||||
defaults={
|
||||
"warehouse": self.warehouse,
|
||||
"company": self.company,
|
||||
"status": "Active",
|
||||
"reference_doctype": self.get("voucher_type"),
|
||||
"reference_name": self.get("voucher_no"),
|
||||
"posting_date": self.get("posting_date") or getdate(self.posting_datetime),
|
||||
"batch_no": self.get("batch_no"),
|
||||
},
|
||||
)
|
||||
except frappe.DuplicateEntryError:
|
||||
frappe.throw(
|
||||
_(
|
||||
"A generated serial number already exists. Change the Serial No Series for Item {0} or correct its current counter."
|
||||
).format(self.item_code),
|
||||
frappe.DuplicateEntryError,
|
||||
)
|
||||
|
||||
if serial_nos_details:
|
||||
fields = [
|
||||
"name",
|
||||
"serial_no",
|
||||
"creation",
|
||||
"modified",
|
||||
"owner",
|
||||
"modified_by",
|
||||
"warehouse",
|
||||
"company",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
"status",
|
||||
"reference_doctype",
|
||||
"reference_name",
|
||||
"posting_date",
|
||||
"batch_no",
|
||||
]
|
||||
|
||||
try:
|
||||
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
|
||||
except Exception as e:
|
||||
if e and len(e.args) > 1 and "Duplicate" in e.args[1]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"A naming series conflict occurred while creating serial numbers. Please change the naming series for the item {0}."
|
||||
).format(bold(self.item_code)),
|
||||
title=_("Duplicate Serial Number Error"),
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
obj.update_counter(current_value)
|
||||
|
||||
return sr_nos
|
||||
series.update_counter(current_value)
|
||||
return [ids[number] for number in numbers]
|
||||
|
||||
|
||||
def get_serial_or_batch_items(items):
|
||||
@@ -1681,7 +1603,10 @@ def throw_negative_batch_validation(batch_no, qty):
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Batch {0} has negative batch quantity {1}. To fix this, go to the batch and click on Recalculate Batch Qty. If the issue still persists, create an inward entry."
|
||||
).format(bold(get_link_to_form("Batch", batch_no)), bold(qty)),
|
||||
).format(
|
||||
bold(get_link_to_form("Batch", batch_no, format_serial_batch_numbers("Batch", [batch_no]))),
|
||||
bold(qty),
|
||||
),
|
||||
title=_("Negative Stock Error"),
|
||||
)
|
||||
|
||||
|
||||
111
erpnext/stock/serial_batch_display.py
Normal file
111
erpnext/stock/serial_batch_display.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Display physical numbers while retaining document IDs for stock references."""
|
||||
|
||||
from copy import copy
|
||||
from functools import wraps
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import escape_html
|
||||
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
|
||||
def format_serial_batch_numbers(doctype: str, names: list[str]) -> str:
|
||||
labels = SerialBatchIdentity(doctype).labels(names)
|
||||
return ", ".join(escape_html(labels.get(name) or name) for name in names)
|
||||
|
||||
|
||||
def with_serial_batch_numbers(execute):
|
||||
@wraps(execute)
|
||||
def wrapped(*args, **kwargs):
|
||||
result = execute(*args, **kwargs)
|
||||
if not result or len(result) < 2:
|
||||
return result
|
||||
columns, rows, *other = result
|
||||
columns, rows = report_number_columns(columns, rows)
|
||||
return columns, rows, *other
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def report_number_columns(columns, rows):
|
||||
from frappe.desk.query_report import get_column_as_dict
|
||||
|
||||
columns = [get_column_as_dict(column) for column in columns]
|
||||
rows = [list(row) if isinstance(row, list | tuple) else row.copy() for row in rows]
|
||||
for index, column in enumerate(list(columns)):
|
||||
doctype = column.get("options")
|
||||
field = column["fieldname"]
|
||||
if doctype not in ("Serial No", "Batch"):
|
||||
if field in ("serial_no", "balance_serial_no"):
|
||||
doctype = "Serial No"
|
||||
else:
|
||||
continue
|
||||
values = [row.get(field) if isinstance(row, dict) else row[index] for row in rows]
|
||||
names = {name for value in values if value for name in str(value).split("\n")}
|
||||
labels = SerialBatchIdentity(doctype).labels(names)
|
||||
number_field = f"{field}_number"
|
||||
columns.append({**column, "hidden": 1, "label": _("{0} ID").format(column["label"])})
|
||||
columns[index] = {
|
||||
"fieldname": number_field,
|
||||
"label": column["label"],
|
||||
"fieldtype": "Serial Batch Number",
|
||||
"options": doctype,
|
||||
"reference_field": field,
|
||||
"width": column.get("width", 140),
|
||||
"hidden": column.get("hidden", 0),
|
||||
}
|
||||
for row, value in zip(rows, values, strict=True):
|
||||
number = "\n".join(labels.get(name, name) for name in str(value).split("\n")) if value else value
|
||||
if isinstance(row, dict):
|
||||
row[number_field] = number
|
||||
else:
|
||||
row.append(value)
|
||||
row[index] = number
|
||||
return columns, rows
|
||||
|
||||
|
||||
class SerialBatchReference(Document):
|
||||
def get_formatted(
|
||||
self, fieldname, doc=None, currency=None, absolute_value=False, translated=False, format=None
|
||||
):
|
||||
if fieldname not in serial_number_fields(self) or not self.get(fieldname):
|
||||
return super().get_formatted(fieldname, doc, currency, absolute_value, translated, format)
|
||||
|
||||
names = self.get(fieldname).split("\n")
|
||||
labels = {}
|
||||
if fieldname not in (self.get("__serial_batch_input") or []):
|
||||
labels = self.get("__serial_number_labels") or {}
|
||||
if any(name not in labels for name in names):
|
||||
set_serial_number_labels(self.parent_doc or self)
|
||||
labels = self.get("__serial_number_labels") or {}
|
||||
|
||||
print_row = copy(self)
|
||||
print_row.set(fieldname, "\n".join(escape_html(labels.get(name, name)) for name in names))
|
||||
return super(SerialBatchReference, print_row).get_formatted(
|
||||
fieldname, doc, currency, absolute_value, translated, format
|
||||
)
|
||||
|
||||
|
||||
def set_serial_number_labels(doc, method=None, print_settings=None):
|
||||
rows = [row for row in [doc, *doc.get_all_children()] if isinstance(row, SerialBatchReference)]
|
||||
names = {
|
||||
name
|
||||
for row in rows
|
||||
for field in serial_number_fields(row)
|
||||
if row.get(field) and field not in (row.get("__serial_batch_input") or [])
|
||||
for name in row.get(field).split("\n")
|
||||
}
|
||||
labels = SerialBatchIdentity("Serial No").labels(names)
|
||||
labels = {name: labels.get(name) or name for name in names}
|
||||
for row in rows:
|
||||
row.__dict__["__serial_number_labels"] = labels
|
||||
|
||||
|
||||
def serial_number_fields(row):
|
||||
return [
|
||||
field
|
||||
for field in ("serial_no", "rejected_serial_no", "current_serial_no")
|
||||
if (meta := row.meta.get_field(field)) and meta.fieldtype in ("Small Text", "Text", "Long Text")
|
||||
]
|
||||
369
erpnext/stock/serial_batch_identity.py
Normal file
369
erpnext/stock/serial_batch_identity.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""Resolve physical numbers at input boundaries. Stock references always contain document IDs."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.naming import make_autoname
|
||||
from frappe.query_builder.functions import Coalesce, Count, Lower, NullIf
|
||||
from frappe.utils import cstr, now
|
||||
|
||||
from erpnext.stock.serial_batch_number_lookup import SerialBatchNumberLookup
|
||||
|
||||
|
||||
class SerialBatchIdentity:
|
||||
def __init__(self, doctype):
|
||||
self.doctype = doctype
|
||||
self.item_field, self.number_field = {
|
||||
"Serial No": ("item_code", "serial_no"),
|
||||
"Batch": ("item", "batch_id"),
|
||||
}[doctype]
|
||||
|
||||
def resolve(self, item_code, numbers, *, create=False, defaults=None, check_permissions=False):
|
||||
"""Return IDs in input order. A physical number is never looked up as a document ID."""
|
||||
if not isinstance(numbers, list | tuple) or any(not isinstance(number, str) for number in numbers):
|
||||
frappe.throw(_("Physical numbers must be a list of strings"))
|
||||
numbers = [number.strip() for number in numbers]
|
||||
if not numbers:
|
||||
return []
|
||||
if not isinstance(item_code, str) or not item_code or any(not number for number in numbers):
|
||||
frappe.throw(_("Item and physical number are required"))
|
||||
|
||||
if check_permissions:
|
||||
permission = "select" if frappe.only_has_select_perm(self.doctype) else "read"
|
||||
frappe.has_permission(self.doctype, permission, throw=True)
|
||||
lookup = SerialBatchNumberLookup(self, item_code, numbers)
|
||||
missing = lookup.missing
|
||||
if missing:
|
||||
if not create:
|
||||
frappe.throw(
|
||||
_("{0} {1} does not exist for Item {2}").format(self.doctype, missing[0], item_code)
|
||||
)
|
||||
if check_permissions:
|
||||
frappe.has_permission(self.doctype, "create", throw=True)
|
||||
lookup.assign(self.resolve_missing(item_code, missing, defaults))
|
||||
names = [lookup.ids[number] for number in numbers]
|
||||
if check_permissions:
|
||||
allowed = frappe.get_list(
|
||||
self.doctype, filters={"name": ("in", names)}, pluck="name", limit_page_length=0
|
||||
)
|
||||
if set(names) - set(allowed):
|
||||
frappe.throw(
|
||||
_("Not permitted to select these serial or batch records"), frappe.PermissionError
|
||||
)
|
||||
return names
|
||||
|
||||
def resolve_missing(self, item_code, numbers, defaults):
|
||||
savepoint = "serial_batch_resolve_" + frappe.generate_hash(length=10)
|
||||
frappe.db.savepoint(savepoint)
|
||||
try:
|
||||
ids = self.create_many(item_code, numbers, defaults)
|
||||
except Exception as error:
|
||||
frappe.db.rollback(save_point=savepoint)
|
||||
frappe.db.release_savepoint(savepoint)
|
||||
if not isinstance(error, frappe.DuplicateEntryError | frappe.UniqueValidationError):
|
||||
raise
|
||||
else:
|
||||
frappe.db.release_savepoint(savepoint)
|
||||
return ids
|
||||
|
||||
lookup = SerialBatchNumberLookup(self, item_code, numbers)
|
||||
if lookup.missing:
|
||||
lookup.assign(self.create_many(item_code, lookup.missing, defaults))
|
||||
return lookup.ids
|
||||
|
||||
def get_query(self, numbers, item_code=None, *, fields=None, filters=None, ignore_permissions=True):
|
||||
table = frappe.qb.DocType(self.doctype)
|
||||
filters = {**(filters or {}), **({self.item_field: item_code} if item_code else {})}
|
||||
return frappe.qb.get_query(
|
||||
self.doctype,
|
||||
fields=fields or ["name", self.number_field],
|
||||
filters=filters,
|
||||
ignore_permissions=ignore_permissions,
|
||||
).where(
|
||||
self.number_key(table[self.number_field]).isin([self.number_key(number) for number in numbers])
|
||||
)
|
||||
|
||||
def number_key(self, value):
|
||||
# Preserve MariaDB's collation. PostgreSQL needs an explicit case-insensitive comparison.
|
||||
return Lower(value) if frappe.db.db_type == "postgres" else value
|
||||
|
||||
def exists(self, number, item_code=None, *, exclude=None):
|
||||
filters = {"name": ("!=", exclude)} if exclude else None
|
||||
rows = self.get_query([number], item_code, fields=["name"], filters=filters).limit(1).run()
|
||||
return rows[0][0] if rows else None
|
||||
|
||||
def create_many(self, item_code, numbers, defaults=None):
|
||||
if self.doctype == "Batch":
|
||||
# New batches still need their expiry and valuation hooks.
|
||||
return {number: self.create_batch(item_code, number, defaults) for number in numbers}
|
||||
|
||||
# Inactive serials can be prepared before their first receipt assigns a company.
|
||||
item = frappe.get_cached_value(
|
||||
"Item", item_code, ["item_name", "description", "warranty_period", "has_serial_no"], as_dict=True
|
||||
)
|
||||
if not item.has_serial_no:
|
||||
frappe.throw(_("Item {0} does not have serial numbers enabled").format(item_code))
|
||||
common = {
|
||||
"item_code": item_code,
|
||||
"item_name": item.item_name,
|
||||
"description": item.description,
|
||||
"warranty_period": item.warranty_period or 0,
|
||||
"status": "Inactive",
|
||||
"creation": now(),
|
||||
"modified": now(),
|
||||
"owner": frappe.session.user,
|
||||
"modified_by": frappe.session.user,
|
||||
**(defaults or {}),
|
||||
}
|
||||
ids = {number: make_autoname("hash", "Serial No") for number in numbers}
|
||||
try:
|
||||
frappe.db.bulk_insert(
|
||||
"Serial No",
|
||||
fields=["name", "serial_no", *common],
|
||||
values=[(name, number, *common.values()) for number, name in ids.items()],
|
||||
)
|
||||
except Exception as error:
|
||||
if frappe.db.is_unique_key_violation(error) or frappe.db.is_primary_key_violation(error):
|
||||
raise frappe.DuplicateEntryError(
|
||||
_("A serial number already exists for Item {0}. Refresh and try again.").format(item_code)
|
||||
) from error
|
||||
raise
|
||||
return ids
|
||||
|
||||
def create_batch(self, item_code, number, defaults=None):
|
||||
doc = frappe.new_doc("Batch")
|
||||
doc.update(defaults or {})
|
||||
doc.item = item_code
|
||||
doc.batch_id = number
|
||||
doc.flags.serial_batch_number_checked = True
|
||||
doc.insert(ignore_permissions=True)
|
||||
return doc.name
|
||||
|
||||
def labels(self, names):
|
||||
if not names:
|
||||
return {}
|
||||
return dict(
|
||||
frappe.get_all(
|
||||
self.doctype,
|
||||
filters={"name": ("in", list(set(names)))},
|
||||
fields=["name", self.number_field],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
def validate(self, doc):
|
||||
number = cstr(doc.get(self.number_field)).strip()
|
||||
doc.set(self.number_field, number)
|
||||
if (
|
||||
number
|
||||
and not (doc.is_new() and doc.flags.serial_batch_number_checked)
|
||||
and self.exists(number, doc.get(self.item_field), exclude=doc.name)
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} {1} already exists for Item {2}").format(
|
||||
self.doctype, number, doc.get(self.item_field)
|
||||
),
|
||||
frappe.DuplicateEntryError,
|
||||
)
|
||||
|
||||
def backfill_numbers(self):
|
||||
table = frappe.qb.DocType(self.doctype)
|
||||
frappe.qb.update(table).set(table[self.number_field], table.name).where(
|
||||
table[self.number_field].isnull() | (table[self.number_field] == "")
|
||||
).run()
|
||||
|
||||
def has_constraint(self):
|
||||
index = (
|
||||
("serial_no_number_item_ci" if self.doctype == "Serial No" else "batch_number_item_ci")
|
||||
if frappe.db.db_type == "postgres"
|
||||
else f"unique_{self.item_field}_{self.number_field}"
|
||||
)
|
||||
return bool(frappe.db.has_index(f"tab{self.doctype}", index))
|
||||
|
||||
def sync_constraint(self):
|
||||
if self.has_constraint():
|
||||
return
|
||||
if self.doctype not in (frappe.flags.serial_batch_preflight or ()):
|
||||
self.validate_existing_numbers()
|
||||
self.backfill_numbers()
|
||||
if frappe.db.db_type == "postgres":
|
||||
# The leading number expression also indexes scans without an item filter.
|
||||
# add_unique only accepts column names, so expression indexes need explicit DDL.
|
||||
frappe.db.sql_ddl(
|
||||
{
|
||||
"Serial No": 'CREATE UNIQUE INDEX IF NOT EXISTS "serial_no_number_item_ci" '
|
||||
'ON "tabSerial No" (lower("serial_no"), "item_code")',
|
||||
"Batch": 'CREATE UNIQUE INDEX IF NOT EXISTS "batch_number_item_ci" '
|
||||
'ON "tabBatch" (lower("batch_id"), "item")',
|
||||
}[self.doctype]
|
||||
)
|
||||
else:
|
||||
frappe.db.add_unique(self.doctype, [self.item_field, self.number_field])
|
||||
|
||||
def validate_existing_numbers(self):
|
||||
table = frappe.qb.DocType(self.doctype)
|
||||
# Use the future backfilled value without changing legacy records during the preflight.
|
||||
number = (
|
||||
Coalesce(NullIf(table[self.number_field], ""), table.name)
|
||||
if frappe.db.has_column(self.doctype, self.number_field)
|
||||
else table.name
|
||||
)
|
||||
key = self.number_key(number)
|
||||
duplicates = (
|
||||
frappe.qb.from_(table)
|
||||
.select(table[self.item_field], key)
|
||||
.groupby(table[self.item_field], key)
|
||||
.having(Count(table.name) > 1)
|
||||
).run()
|
||||
if not duplicates:
|
||||
return
|
||||
|
||||
conflicts = []
|
||||
for item, value in duplicates:
|
||||
names = (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.name)
|
||||
.where((table[self.item_field] == item) & (key == value))
|
||||
.orderby(table.name)
|
||||
).run(pluck=True)
|
||||
conflicts.append(_("Item {0}, number {1}: {2}").format(item, value, ", ".join(names)))
|
||||
frappe.throw(
|
||||
_(
|
||||
"Resolve duplicate {0} physical numbers before upgrading. Document IDs and stock references have not been changed."
|
||||
).format(self.doctype)
|
||||
+ "\n"
|
||||
+ "\n".join(conflicts),
|
||||
title=_("Duplicate Serial or Batch Numbers"),
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def resolve_serial_batch_numbers(
|
||||
item_code: str,
|
||||
serial_numbers: list | str | None = None,
|
||||
batch_numbers: list | str | None = None,
|
||||
create: bool = False,
|
||||
):
|
||||
"""Resolve physical input to Link values. Existing ID-based APIs keep their meaning."""
|
||||
frappe.has_permission("Item", "read", doc=item_code, throw=True)
|
||||
result = {}
|
||||
for doctype, values, key in (
|
||||
("Serial No", serial_numbers, "serial_nos"),
|
||||
("Batch", batch_numbers, "batch_nos"),
|
||||
):
|
||||
values = frappe.parse_json(values) or []
|
||||
result[key] = SerialBatchIdentity(doctype).resolve(
|
||||
item_code, values, create=create, check_permissions=True
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["GET", "POST"])
|
||||
def get_serial_batch_labels(doctype: str, names: list | str):
|
||||
if doctype not in ("Serial No", "Batch"):
|
||||
frappe.throw(_("Only Serial No and Batch labels are supported"))
|
||||
names = frappe.parse_json(names)
|
||||
if not isinstance(names, list) or any(not isinstance(name, str) for name in names):
|
||||
frappe.throw(_("Document IDs must be a list of strings"))
|
||||
identity = SerialBatchIdentity(doctype)
|
||||
return dict(
|
||||
frappe.get_list(
|
||||
doctype,
|
||||
filters={"name": ("in", names)},
|
||||
fields=["name", identity.number_field],
|
||||
as_list=True,
|
||||
limit_page_length=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def resolve_transaction_serial_numbers(parent: dict | str, row: dict | str, numbers: list | str):
|
||||
parent, row = frappe.parse_json(parent), frappe.parse_json(row)
|
||||
if not isinstance(parent, dict) or not isinstance(parent.get("doctype"), str):
|
||||
frappe.throw(_("Transaction DocType is required"))
|
||||
if not frappe.db.exists("DocType", parent["doctype"]):
|
||||
frappe.throw(_("Invalid transaction DocType"))
|
||||
if not isinstance(row, dict) or not (row.get("item_code") or row.get("rm_item_code")):
|
||||
frappe.throw(_("Item is required"))
|
||||
frappe.has_permission(
|
||||
parent["doctype"],
|
||||
"write",
|
||||
doc=parent.get("name") if not parent.get("__islocal") else None,
|
||||
throw=True,
|
||||
)
|
||||
return resolve_serial_batch_numbers(
|
||||
row.get("item_code") or row.get("rm_item_code"), serial_numbers=numbers
|
||||
)["serial_nos"]
|
||||
|
||||
|
||||
def add_number_labels(entries):
|
||||
"""Attach display values without changing the references or their field names."""
|
||||
for field, doctype, label in (
|
||||
("serial_no", "Serial No", "serial_number"),
|
||||
("batch_no", "Batch", "batch_number"),
|
||||
):
|
||||
labels = SerialBatchIdentity(doctype).labels([row.get(field) for row in entries if row.get(field)])
|
||||
for row in entries:
|
||||
row[label] = labels.get(row.get(field), row.get(field))
|
||||
return entries
|
||||
|
||||
|
||||
def resolve_number_entries(item_code, entries, *, create=False):
|
||||
"""Only explicit physical-number fields are resolved. Link fields already contain IDs."""
|
||||
for field, doctype, number_field in (
|
||||
("batch_no", "Batch", "batch_number"),
|
||||
("serial_no", "Serial No", "serial_number"),
|
||||
):
|
||||
rows = [row for row in entries if row.get(number_field) and not row.get(field)]
|
||||
ids = (
|
||||
resolve_serial_batch_numbers(
|
||||
item_code,
|
||||
**{
|
||||
"serial_numbers" if doctype == "Serial No" else "batch_numbers": [
|
||||
row[number_field] for row in rows
|
||||
]
|
||||
},
|
||||
create=create,
|
||||
)["serial_nos" if doctype == "Serial No" else "batch_nos"]
|
||||
if rows
|
||||
else []
|
||||
)
|
||||
for row, name in zip(rows, ids, strict=True):
|
||||
row[field] = name
|
||||
serials = [row["serial_no"] for row in entries if row.get("serial_no") and not row.get("batch_no")]
|
||||
batches = (
|
||||
dict(
|
||||
frappe.get_all(
|
||||
"Serial No",
|
||||
filters={"name": ("in", serials), "item_code": item_code},
|
||||
fields=["name", "batch_no"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
if serials
|
||||
else {}
|
||||
)
|
||||
for row in entries:
|
||||
if not row.get("batch_no") and row.get("serial_no") in batches and batches[row["serial_no"]]:
|
||||
row["batch_no"] = batches[row["serial_no"]]
|
||||
return entries
|
||||
|
||||
|
||||
def validate_item_merge(old, new):
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
identity = SerialBatchIdentity(doctype)
|
||||
source = frappe.qb.DocType(doctype).as_("source")
|
||||
target = frappe.qb.DocType(doctype).as_("target")
|
||||
conflict = (
|
||||
frappe.qb.from_(source)
|
||||
.join(target)
|
||||
.on(
|
||||
identity.number_key(source[identity.number_field])
|
||||
== identity.number_key(target[identity.number_field])
|
||||
)
|
||||
.select(source[identity.number_field])
|
||||
.where((source[identity.item_field] == old) & (target[identity.item_field] == new))
|
||||
.limit(1)
|
||||
).run()
|
||||
if conflict:
|
||||
frappe.throw(_("Cannot merge items with the same {0}: {1}").format(doctype, conflict[0][0]))
|
||||
59
erpnext/stock/serial_batch_import.py
Normal file
59
erpnext/stock/serial_batch_import.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from copy import copy
|
||||
|
||||
import frappe
|
||||
from frappe.core.doctype.data_import.importer import Importer, Row
|
||||
|
||||
from erpnext.stock.serial_batch_input import NUMBER_FIELDS
|
||||
|
||||
|
||||
class SerialBatchDataImport:
|
||||
def get_importer(self):
|
||||
if not has_number_inputs(self.reference_doctype):
|
||||
return super().get_importer()
|
||||
return SerialBatchImporter(self.reference_doctype, data_import=self, use_sniffer=self.use_csv_sniffer)
|
||||
|
||||
|
||||
class SerialBatchImporter(Importer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.template_options.get("serial_batch_input") is False:
|
||||
return
|
||||
for column in self.import_file.header.columns:
|
||||
df = column.df
|
||||
if (
|
||||
column.skip_import
|
||||
or not df
|
||||
or not frappe.get_meta(df.parent).has_field("serial_and_batch_bundle")
|
||||
):
|
||||
continue
|
||||
if df.fieldname == "batch_no":
|
||||
# Import cells contain physical numbers; validate their links after item resolution.
|
||||
column.df = copy(df)
|
||||
column.df.fieldtype = "Data"
|
||||
column.warnings = [warning for warning in column.warnings if warning.get("type") == "info"]
|
||||
column.invalid_value_items = None
|
||||
self.import_file.data = [
|
||||
SerialBatchImportRow(row.index, row.data, row.doctype, row.header, row.import_type)
|
||||
for row in self.import_file.data
|
||||
]
|
||||
|
||||
|
||||
class SerialBatchImportRow(Row):
|
||||
def _parse_doc(self, doctype, columns, values, parent_doc=None, table_df=None):
|
||||
doc = super()._parse_doc(doctype, columns, values, parent_doc, table_df)
|
||||
if frappe.get_meta(doctype).has_field("serial_and_batch_bundle"):
|
||||
fields = [
|
||||
column.df.fieldname
|
||||
for column, value in zip(columns, values, strict=True)
|
||||
if column.df.fieldname in NUMBER_FIELDS and value not in (None, "")
|
||||
]
|
||||
if fields:
|
||||
doc.update({"__serial_batch_input": fields})
|
||||
return doc
|
||||
|
||||
|
||||
def has_number_inputs(doctype):
|
||||
return any(
|
||||
frappe.get_meta(df.options).has_field("serial_and_batch_bundle")
|
||||
for df in frappe.get_meta(doctype).get_table_fields()
|
||||
)
|
||||
80
erpnext/stock/serial_batch_input.py
Normal file
80
erpnext/stock/serial_batch_input.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
NUMBER_FIELDS = ("batch_no", "serial_no", "rejected_serial_no", "current_serial_no")
|
||||
|
||||
|
||||
def resolve_transaction_numbers(doc, method=None):
|
||||
if doc.docstatus == 2:
|
||||
return
|
||||
for row in [doc, *doc.get_all_children()]:
|
||||
if row.meta.has_field("serial_and_batch_bundle"):
|
||||
TransactionNumberInput(doc, row).resolve()
|
||||
|
||||
|
||||
class TransactionNumberInput:
|
||||
def __init__(self, doc, row):
|
||||
self.doc = doc
|
||||
self.row = row
|
||||
self.item_code = row.get("item_code") or row.get("rm_item_code")
|
||||
|
||||
def resolve(self):
|
||||
fields = self.row.get("__serial_batch_input")
|
||||
if fields is None:
|
||||
return
|
||||
if not isinstance(fields, list) or any(field not in NUMBER_FIELDS for field in fields):
|
||||
frappe.throw(_("Physical input must identify serial or batch fields"))
|
||||
for field in NUMBER_FIELDS:
|
||||
if field not in fields:
|
||||
continue
|
||||
value = self.row.get(field)
|
||||
if value is None:
|
||||
value = ""
|
||||
if not isinstance(value, str):
|
||||
frappe.throw(_("Physical numbers must be text"))
|
||||
numbers = (
|
||||
[value.strip()]
|
||||
if field == "batch_no" and value.strip()
|
||||
else [number.strip() for number in value.replace(",", "\n").splitlines() if number.strip()]
|
||||
)
|
||||
if field == "batch_no" and len(numbers) > 1:
|
||||
frappe.throw(_("Enter one physical batch number per row"))
|
||||
names = self.resolve_numbers(field, numbers) if numbers else []
|
||||
self.row.set(field, "\n".join(names))
|
||||
fields.remove(field)
|
||||
if names and self.row.meta.has_field("use_serial_batch_fields"):
|
||||
self.row.use_serial_batch_fields = 1
|
||||
self.row.__dict__.pop("__serial_batch_input", None)
|
||||
|
||||
def resolve_numbers(self, field, numbers):
|
||||
doctype = "Batch" if field == "batch_no" else "Serial No"
|
||||
identity = SerialBatchIdentity(doctype)
|
||||
if not self.item_code:
|
||||
frappe.throw(_("Item is required"))
|
||||
frappe.has_permission("Item", "read", doc=self.item_code, throw=True)
|
||||
names = identity.resolve(
|
||||
self.item_code,
|
||||
numbers,
|
||||
create=self.can_create(field),
|
||||
defaults={"company": self.doc.get("company")},
|
||||
check_permissions=True,
|
||||
)
|
||||
if doctype == "Serial No" and len(set(names)) != len(names):
|
||||
frappe.throw(_("A serial number cannot appear twice in the same row"))
|
||||
return names
|
||||
|
||||
def can_create(self, field):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.inline_editor import SUPPORTED_VOUCHER_TYPES
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_type_of_transaction,
|
||||
)
|
||||
|
||||
row = frappe._dict(self.row.as_dict())
|
||||
row.pop("type_of_transaction", None)
|
||||
return (
|
||||
field != "current_serial_no"
|
||||
and self.doc.doctype in SUPPORTED_VOUCHER_TYPES
|
||||
and get_type_of_transaction(self.doc, row) == "Inward"
|
||||
)
|
||||
61
erpnext/stock/serial_batch_number_lookup.py
Normal file
61
erpnext/stock/serial_batch_number_lookup.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import frappe
|
||||
from frappe.query_builder.terms import ParameterizedValueWrapper
|
||||
from pypika.analytics import Min
|
||||
|
||||
|
||||
class SerialBatchNumberLookup:
|
||||
"""Match inputs and group aliases using the physical column's database collation."""
|
||||
|
||||
def __init__(self, identity, item_code, numbers):
|
||||
self.identity = identity
|
||||
self.item_code = item_code
|
||||
self.numbers = list(dict.fromkeys(numbers))
|
||||
self.ids = {}
|
||||
self.aliases = {}
|
||||
self.load()
|
||||
|
||||
@property
|
||||
def missing(self):
|
||||
return [
|
||||
number for number in self.numbers if number not in self.ids and self.aliases[number] == number
|
||||
]
|
||||
|
||||
def load(self):
|
||||
table = frappe.qb.DocType(self.identity.doctype)
|
||||
inputs = self.get_inputs(table)
|
||||
key = self.identity.number_key
|
||||
rows = (
|
||||
frappe.qb.from_(inputs)
|
||||
.left_join(table)
|
||||
.on(
|
||||
(table[self.identity.item_field] == self.item_code)
|
||||
& (key(table[self.identity.number_field]) == key(inputs.number))
|
||||
)
|
||||
.select(inputs.ordinal, table.name, Min(inputs.ordinal).over(key(inputs.number)))
|
||||
).run()
|
||||
for index, name, first_index in rows:
|
||||
number = self.numbers[index]
|
||||
self.aliases[number] = self.numbers[first_index]
|
||||
if name:
|
||||
self.ids[number] = name
|
||||
|
||||
def get_inputs(self, table):
|
||||
# An empty column select preserves MariaDB's physical-number collation in the union.
|
||||
inputs = (
|
||||
frappe.qb.from_(table)
|
||||
.select(
|
||||
table[self.identity.number_field].as_("number"), ParameterizedValueWrapper(-1).as_("ordinal")
|
||||
)
|
||||
.where(table.name.isnull())
|
||||
)
|
||||
for index, number in enumerate(self.numbers):
|
||||
inputs = inputs.union_all(
|
||||
frappe.qb.select(
|
||||
ParameterizedValueWrapper(number).as_("number"),
|
||||
ParameterizedValueWrapper(index).as_("ordinal"),
|
||||
)
|
||||
)
|
||||
return inputs.as_("numbers")
|
||||
|
||||
def assign(self, names):
|
||||
self.ids.update({number: names[alias] for number, alias in self.aliases.items() if alias in names})
|
||||
@@ -77,7 +77,7 @@ class TestGetItemDetail(ERPNextTestSuite):
|
||||
).insert()
|
||||
|
||||
# create batch
|
||||
frappe.get_doc(
|
||||
batch = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": "BATCH01",
|
||||
@@ -92,7 +92,7 @@ class TestGetItemDetail(ERPNextTestSuite):
|
||||
"price_list": "Standard Selling",
|
||||
"item_code": item.item_code,
|
||||
"price_list_rate": 50,
|
||||
"batch_no": "BATCH01",
|
||||
"batch_no": batch.name,
|
||||
}
|
||||
).insert()
|
||||
|
||||
@@ -104,7 +104,7 @@ class TestGetItemDetail(ERPNextTestSuite):
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
qty=100,
|
||||
rate=100,
|
||||
batch_no="BATCH01",
|
||||
batch_no=batch.name,
|
||||
)
|
||||
|
||||
# creating sales order just to create delivery note from it
|
||||
@@ -122,7 +122,7 @@ class TestGetItemDetail(ERPNextTestSuite):
|
||||
|
||||
# Test 2 : On saving the DN, item's batch will be fetched and rate will be updated from Item Price
|
||||
dn.save()
|
||||
self.assertEqual(dn.items[0].batch_no, "BATCH01")
|
||||
self.assertEqual(dn.items[0].batch_no, batch.name)
|
||||
self.assertEqual(dn.items[0].rate, 50)
|
||||
|
||||
def test_maintain_same_rate_keeps_source_rate_on_refetch(self):
|
||||
@@ -521,6 +521,32 @@ class TestGetItemDetail(ERPNextTestSuite):
|
||||
)
|
||||
self.assertEqual({d.batch_no: d.qty for d in entries}, {batches[0]: -2, batches[1]: -3})
|
||||
|
||||
def test_scanned_serial_and_batch_preserved_during_item_selection(self):
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
item_code, batches = self.make_batched_item_with_stock(
|
||||
[1, 1], has_serial_no=1, serial_no_series="SCAN-SN-.#####"
|
||||
)
|
||||
serial_no = frappe.db.get_value("Serial No", {"item_code": item_code, "batch_no": batches[1]})
|
||||
|
||||
with self.change_settings(
|
||||
"Stock Settings",
|
||||
{"pick_serial_and_batch_based_on": "FIFO", "auto_create_serial_and_batch_bundle_for_outward": 1},
|
||||
):
|
||||
self.assertEqual(self.get_picked_batch_no(item_code, 1), batches[0])
|
||||
dn = create_delivery_note(
|
||||
item_code=item_code,
|
||||
qty=1,
|
||||
use_serial_batch_fields=1,
|
||||
serial_no=serial_no,
|
||||
batch_no=batches[1],
|
||||
do_not_save=True,
|
||||
)
|
||||
dn.process_item_selection(item_idx=dn.items[0].idx, reset_item_details=True)
|
||||
self.assertEqual(dn.items[0].serial_no, serial_no)
|
||||
self.assertEqual(dn.items[0].batch_no, batches[1])
|
||||
self.assertEqual(dn.items[0].qty, 1)
|
||||
|
||||
def test_serial_nos_picked_across_batches_when_no_batch_covers_qty(self):
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
|
||||
314
erpnext/stock/tests/test_serial_batch_identity.py
Normal file
314
erpnext/stock/tests/test_serial_batch_identity.py
Normal file
@@ -0,0 +1,314 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.inline_editor import (
|
||||
get_bundle_entries,
|
||||
upsert_bundle_entries,
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import get_serial_or_batch_nos
|
||||
from erpnext.stock.serial_batch_identity import (
|
||||
SerialBatchIdentity,
|
||||
resolve_number_entries,
|
||||
validate_item_merge,
|
||||
)
|
||||
from erpnext.stock.utils import scan_barcode
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSerialBatchIdentity(ERPNextTestSuite):
|
||||
def make_item(self, serialized=False):
|
||||
return make_item(properties={"has_serial_no": int(serialized), "has_batch_no": int(not serialized)})
|
||||
|
||||
def create_number(self, item, number, serialized=False):
|
||||
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
|
||||
return identity.resolve(item.name, [number], create=True, defaults={"company": "_Test Company"})[0]
|
||||
|
||||
def test_numbers_are_unique_within_item(self):
|
||||
for serialized in (False, True):
|
||||
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
|
||||
items = [self.make_item(serialized) for _ in range(2)]
|
||||
number = frappe.generate_hash()
|
||||
ids = [self.create_number(item, number, serialized) for item in items]
|
||||
self.assertNotEqual(ids[0], ids[1])
|
||||
self.assertNotIn(number, ids)
|
||||
for item, name in zip(items, ids, strict=True):
|
||||
self.assertEqual(identity.resolve(item.name, [number]), [name])
|
||||
self.assertEqual(identity.resolve(item.name, [number], create=True), [name])
|
||||
duplicate = frappe.copy_doc(frappe.get_doc(identity.doctype, ids[0]))
|
||||
duplicate.set(identity.number_field, number)
|
||||
with self.assertRaises(frappe.DuplicateEntryError):
|
||||
duplicate.insert()
|
||||
|
||||
def test_database_rejects_duplicate_even_without_controller_validation(self):
|
||||
for serialized in (False, True):
|
||||
item = self.make_item(serialized)
|
||||
name = self.create_number(item, frappe.generate_hash(), serialized)
|
||||
doctype = "Serial No" if serialized else "Batch"
|
||||
duplicate = frappe.get_doc(doctype, name)
|
||||
duplicate.name = frappe.generate_hash()
|
||||
frappe.db.savepoint("duplicate_number")
|
||||
try:
|
||||
with self.assertRaises((frappe.DuplicateEntryError, frappe.UniqueValidationError)):
|
||||
duplicate.db_insert()
|
||||
finally:
|
||||
frappe.db.rollback(save_point="duplicate_number")
|
||||
|
||||
def test_legacy_id_is_not_used_to_resolve_another_items_number(self):
|
||||
for serialized in (False, True):
|
||||
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
|
||||
old_item, new_item = self.make_item(serialized), self.make_item(serialized)
|
||||
old_id = self.create_number(old_item, frappe.generate_hash(), serialized)
|
||||
frappe.db.set_value(identity.doctype, old_id, identity.number_field, old_id)
|
||||
new_id = self.create_number(new_item, old_id, serialized)
|
||||
self.assertEqual(identity.resolve(old_item.name, [old_id]), [old_id])
|
||||
self.assertEqual(identity.resolve(new_item.name, [old_id]), [new_id])
|
||||
self.assertEqual(scan_barcode(old_id, {"item_code": new_item.name})["item_code"], new_item.name)
|
||||
|
||||
def test_ambiguous_scan_and_new_match(self):
|
||||
for serialized in (False, True):
|
||||
items = [self.make_item(serialized) for _ in range(2)]
|
||||
number = frappe.generate_hash()
|
||||
self.create_number(items[0], number, serialized)
|
||||
self.assertEqual(scan_barcode(number)["item_code"], items[0].name)
|
||||
self.create_number(items[1], number, serialized)
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
scan_barcode(number)
|
||||
matches = scan_barcode(number, allow_multiple=True)["candidates"]
|
||||
self.assertEqual({row.item_code for row in matches}, {item.name for item in items})
|
||||
|
||||
def test_serial_scan_includes_the_physical_batch_number(self):
|
||||
item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1})
|
||||
batch = SerialBatchIdentity("Batch").resolve(item.name, ["Scanned-Batch"], create=True)[0]
|
||||
serial = SerialBatchIdentity("Serial No").resolve(
|
||||
item.name, ["Scanned-Serial"], create=True, defaults={"batch_no": batch}
|
||||
)[0]
|
||||
match = scan_barcode("Scanned-Serial", {"item_code": item.name})
|
||||
self.assertEqual((match.serial_no, match.batch_no), (serial, batch))
|
||||
self.assertEqual((match.serial_number, match.batch_number), ("Scanned-Serial", "Scanned-Batch"))
|
||||
|
||||
def test_receipts_store_ids_and_display_numbers(self):
|
||||
for serialized in (False, True):
|
||||
number = frappe.generate_hash()
|
||||
for item in [self.make_item(serialized) for _ in range(2)]:
|
||||
pr = make_purchase_receipt(item_code=item.name, qty=1, rate=100, do_not_submit=True)
|
||||
field = "serial_number" if serialized else "batch_number"
|
||||
link = "serial_no" if serialized else "batch_no"
|
||||
summary = upsert_bundle_entries(
|
||||
pr.items[0].as_dict(), pr.as_dict(), [{field: number, "qty": 1}]
|
||||
)
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
pr.save()
|
||||
pr.submit()
|
||||
rows = get_bundle_entries(summary.bundle)["entries"]
|
||||
self.assertNotEqual(rows[0][link], number)
|
||||
self.assertEqual(rows[0][field], number)
|
||||
self.assertIn(number, get_serial_or_batch_nos(summary.bundle))
|
||||
self.assertNotIn(rows[0][link], get_serial_or_batch_nos(summary.bundle))
|
||||
pr.cancel()
|
||||
|
||||
def test_explicit_links_are_not_reinterpreted(self):
|
||||
item = self.make_item(True)
|
||||
name = self.create_number(item, frappe.generate_hash(), True)
|
||||
rows = [{"serial_no": name}]
|
||||
resolve_number_entries(item.name, rows)
|
||||
self.assertEqual(rows, [{"serial_no": name}])
|
||||
|
||||
def test_item_merge_rejects_shared_numbers(self):
|
||||
for serialized in (False, True):
|
||||
items = [self.make_item(serialized) for _ in range(2)]
|
||||
number = frappe.generate_hash()
|
||||
for item in items:
|
||||
self.create_number(item, number, serialized)
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
validate_item_merge(items[0].name, items[1].name)
|
||||
|
||||
def test_transfers_and_returns_keep_matching_numbers_separate(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
for serialized in (False, True):
|
||||
items = [self.make_item(serialized) for _ in range(2)]
|
||||
number = frappe.generate_hash()
|
||||
ids = [self.create_number(item, number, serialized) for item in items]
|
||||
link = "serial_no" if serialized else "batch_no"
|
||||
receipts = []
|
||||
for item, name, rate in zip(items, ids, (11, 42), strict=True):
|
||||
receipts.append(
|
||||
make_purchase_receipt(
|
||||
item_code=item.name, qty=1, rate=rate, **{link: [name] if serialized else name}
|
||||
)
|
||||
)
|
||||
transfer = make_stock_entry(
|
||||
item_code=items[0].name,
|
||||
qty=1,
|
||||
from_warehouse="_Test Warehouse - _TC",
|
||||
to_warehouse="_Test Warehouse 1 - _TC",
|
||||
**{link: [ids[0]] if serialized else ids[0]},
|
||||
)
|
||||
if serialized:
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", ids[1], "warehouse"), "_Test Warehouse - _TC"
|
||||
)
|
||||
issue = make_stock_entry(
|
||||
item_code=items[0].name,
|
||||
qty=1,
|
||||
from_warehouse="_Test Warehouse 1 - _TC",
|
||||
**{link: [ids[0]] if serialized else ids[0]},
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": issue.name, "is_cancelled": 0},
|
||||
"stock_value_difference",
|
||||
),
|
||||
-11,
|
||||
)
|
||||
issue.cancel()
|
||||
transfer.cancel()
|
||||
purchase_return = make_purchase_return(receipts[0].name)
|
||||
purchase_return.insert().submit()
|
||||
if serialized:
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", ids[1], "warehouse"), "_Test Warehouse - _TC"
|
||||
)
|
||||
purchase_return.cancel()
|
||||
for receipt in receipts:
|
||||
receipt.cancel()
|
||||
|
||||
def test_combined_serial_batch_csv_resolves_both_links(self):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
parse_csv_file_to_get_serial_batch,
|
||||
)
|
||||
|
||||
item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1})
|
||||
serials, batches = parse_csv_file_to_get_serial_batch(
|
||||
[["Serial No", "Batch No", "Quantity"], ["physical-serial", "physical-batch", "1"]]
|
||||
)
|
||||
resolve_number_entries(item.name, serials, create=True)
|
||||
resolve_number_entries(item.name, batches, create=True)
|
||||
self.assertEqual(serials[0]["batch_no"], batches[0]["batch_no"])
|
||||
self.assertNotEqual(serials[0]["serial_no"], "physical-serial")
|
||||
self.assertNotEqual(serials[0]["batch_no"], "physical-batch")
|
||||
|
||||
def test_report_keeps_ids_and_exposes_physical_columns(self):
|
||||
from erpnext.stock.serial_batch_display import report_number_columns
|
||||
|
||||
item = self.make_item(True)
|
||||
name = self.create_number(item, "physical-report-number", True)
|
||||
columns, rows = report_number_columns(
|
||||
[{"label": "Serial No", "fieldname": "serial_no", "fieldtype": "Link", "options": "Serial No"}],
|
||||
[{"serial_no": name}],
|
||||
)
|
||||
self.assertEqual(rows[0]["serial_no"], name)
|
||||
self.assertEqual(rows[0]["serial_no_number"], "physical-report-number")
|
||||
self.assertEqual(columns[1]["hidden"], 1)
|
||||
self.assertFalse(columns[0]["hidden"])
|
||||
|
||||
def test_migration_backfill_preserves_stock_references(self):
|
||||
for serialized in (False, True):
|
||||
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
|
||||
item = self.make_item(serialized)
|
||||
name = self.create_number(item, "legacy-number", serialized)
|
||||
link = "serial_no" if serialized else "batch_no"
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item.name, qty=1, rate=100, **{link: [name] if serialized else name}
|
||||
)
|
||||
bundle = pr.items[0].serial_and_batch_bundle
|
||||
before = frappe.get_doc("Serial and Batch Bundle", bundle).as_dict()
|
||||
frappe.db.set_value(identity.doctype, name, identity.number_field, "")
|
||||
identity.backfill_numbers()
|
||||
identity.backfill_numbers()
|
||||
self.assertEqual(frappe.db.get_value(identity.doctype, name, identity.number_field), name)
|
||||
self.assertEqual(frappe.get_doc("Serial and Batch Bundle", bundle).as_dict(), before)
|
||||
pr.cancel()
|
||||
|
||||
def test_print_formats_physical_serials_without_changing_stored_ids(self):
|
||||
item = self.make_item(True)
|
||||
name = self.create_number(item, "PRINT-123", True)
|
||||
pr = make_purchase_receipt(item_code=item.name, qty=1, rate=100, serial_no=[name])
|
||||
print_doc = frappe.get_doc("Purchase Receipt", pr.name)
|
||||
print_doc.items[0].serial_no = name
|
||||
entry = frappe.get_doc("Serial and Batch Bundle", pr.items[0].serial_and_batch_bundle).entries[0]
|
||||
self.assertEqual(entry.serial_no, name)
|
||||
print_format = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Print Format",
|
||||
"name": "Serial Identity Test Print",
|
||||
"doc_type": "Purchase Receipt",
|
||||
"print_format_type": "Jinja",
|
||||
"custom_format": 1,
|
||||
"html": "{{ doc.items[0].get_formatted('serial_no') }}",
|
||||
}
|
||||
).insert()
|
||||
for _ in range(2):
|
||||
printed = frappe.get_print(
|
||||
"Purchase Receipt", pr.name, print_format=print_format.name, doc=print_doc
|
||||
)
|
||||
self.assertEqual(print_doc.items[0].serial_no, name)
|
||||
self.assertEqual(print_doc.as_dict()["items"][0]["serial_no"], name)
|
||||
self.assertIn("PRINT-123", printed)
|
||||
self.assertNotIn(name, printed)
|
||||
pr.cancel()
|
||||
|
||||
def test_number_search_returns_ids_and_physical_titles(self):
|
||||
from erpnext.stock.report.serial_and_batch_summary.serial_and_batch_summary import get_number_options
|
||||
|
||||
for serialized in (False, True):
|
||||
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
|
||||
item = self.make_item(serialized)
|
||||
name = self.create_number(item, "SEARCH-123", serialized)
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item.name,
|
||||
qty=1,
|
||||
rate=100,
|
||||
**({"serial_no": [name]} if serialized else {"batch_no": name}),
|
||||
)
|
||||
for filters in ({"item_code": item.name}, {"voucher_no": [pr.name]}):
|
||||
options = get_number_options(identity.doctype, "SEARCH", 0, 20, filters)
|
||||
self.assertEqual([tuple(row) for row in options], [(name, "SEARCH-123")])
|
||||
self.assertEqual(
|
||||
[tuple(row) for row in get_number_options(identity.doctype, name, 0, 20, filters)],
|
||||
[(name, "SEARCH-123")],
|
||||
)
|
||||
pr.cancel()
|
||||
|
||||
def test_combined_serial_batch_receipts_keep_items_separate(self):
|
||||
for _item in range(2):
|
||||
item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1})
|
||||
pr = make_purchase_receipt(item_code=item.name, qty=1, rate=100, do_not_submit=True)
|
||||
summary = upsert_bundle_entries(
|
||||
pr.items[0].as_dict(),
|
||||
pr.as_dict(),
|
||||
[{"serial_number": "COMBINED-SERIAL", "batch_number": "COMBINED-BATCH", "qty": 1}],
|
||||
)
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
pr.save().submit()
|
||||
entry = frappe.get_doc("Serial and Batch Bundle", summary.bundle).entries[0]
|
||||
self.assertNotEqual(entry.serial_no, "COMBINED-SERIAL")
|
||||
self.assertNotEqual(entry.batch_no, "COMBINED-BATCH")
|
||||
self.assertEqual(frappe.db.get_value("Serial No", entry.serial_no, "batch_no"), entry.batch_no)
|
||||
self.assertEqual(frappe.db.get_value("Batch", entry.batch_no, "item"), item.name)
|
||||
pr.cancel()
|
||||
|
||||
def test_pos_search_returns_all_matching_items(self):
|
||||
from erpnext.selling.page.point_of_sale.point_of_sale import search_by_term
|
||||
|
||||
for serialized in (False, True):
|
||||
items = [self.make_item(serialized) for _ in range(2)]
|
||||
number = "POS-" + frappe.generate_hash()
|
||||
ids = [self.create_number(item, number, serialized) for item in items]
|
||||
result = search_by_term(number, "_Test Warehouse - _TC", "Standard Selling")
|
||||
self.assertTrue(result["requires_selection"])
|
||||
self.assertEqual({row["item_code"] for row in result["items"]}, {item.name for item in items})
|
||||
field = "serial_no" if serialized else "batch_no"
|
||||
self.assertEqual({row[field] for row in result["items"]}, set(ids))
|
||||
|
||||
def test_empty_batch_link_validation_accepts_the_resolved_id(self):
|
||||
from erpnext.controllers.queries import get_batch_no
|
||||
|
||||
item = self.make_item()
|
||||
name = self.create_number(item, "EMPTY-BATCH")
|
||||
for text in ("EMPTY-BATCH", name):
|
||||
options = get_batch_no("Batch", text, "name", 0, 20, {"item_code": item.name, "is_inward": 1})
|
||||
self.assertEqual([(row[0], row[1]) for row in options], [(name, "EMPTY-BATCH")])
|
||||
95
erpnext/stock/tests/test_serial_batch_identity_access.py
Normal file
95
erpnext/stock/tests/test_serial_batch_identity_access.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity, resolve_transaction_serial_numbers
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSerialBatchIdentityAccess(ERPNextTestSuite):
|
||||
def make_stock_user(self):
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": f"serial-access-{frappe.generate_hash()}@example.test",
|
||||
"first_name": "Serial Access Test",
|
||||
"send_welcome_email": 0,
|
||||
"roles": [{"role": "Stock User"}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
def test_read_only_resolution_never_creates_missing_serials(self):
|
||||
item = make_item(properties={"has_serial_no": 1})
|
||||
user = self.make_stock_user()
|
||||
parent = {"doctype": "Purchase Receipt", "__islocal": 1, "company": "_Test Company"}
|
||||
row = {"item_code": item.name, "qty": 1}
|
||||
with self.set_user(user.name):
|
||||
self.assertTrue(frappe.has_permission("Purchase Receipt", "write"))
|
||||
self.assertTrue(frappe.has_permission("Serial No", "read"))
|
||||
self.assertFalse(frappe.has_permission("Serial No", "create"))
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
resolve_transaction_serial_numbers(parent, row, ["UNAUTHORIZED-SERIAL"])
|
||||
self.assertFalse(frappe.db.exists("Serial No", {"item_code": item.name}))
|
||||
|
||||
def test_readers_can_resolve_existing_outward_serials(self):
|
||||
item = make_item(properties={"has_serial_no": 1})
|
||||
parent = {"doctype": "Purchase Receipt", "__islocal": 1, "company": "_Test Company"}
|
||||
row = {"item_code": item.name, "qty": 1}
|
||||
names = SerialBatchIdentity("Serial No").resolve(item.name, ["EXISTING-SERIAL"], create=True)
|
||||
parent["is_return"] = 1
|
||||
row["qty"] = -1
|
||||
user = self.make_stock_user()
|
||||
with self.set_user(user.name):
|
||||
self.assertFalse(frappe.has_permission("Serial No", "create"))
|
||||
self.assertEqual(resolve_transaction_serial_numbers(parent, row, ["EXISTING-SERIAL"]), names)
|
||||
|
||||
def test_even_authorized_resolution_does_not_create_serials(self):
|
||||
item = make_item(properties={"has_serial_no": 1})
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
resolve_transaction_serial_numbers(
|
||||
{"doctype": "Purchase Receipt", "__islocal": 1, "company": "_Test Company"},
|
||||
{"item_code": item.name},
|
||||
["MISSING-SERIAL"],
|
||||
)
|
||||
self.assertFalse(frappe.db.exists("Serial No", {"item_code": item.name}))
|
||||
|
||||
def test_missing_transaction_doctype_is_a_validation_error(self):
|
||||
for parent in ({}, {"doctype": ""}, {"doctype": "No Such Transaction"}, "[]"):
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
resolve_transaction_serial_numbers(parent, {"item_code": "Item"}, ["SERIAL"])
|
||||
|
||||
def test_draft_save_requires_permission_to_create_missing_serials(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
item = make_item(properties={"has_serial_no": 1})
|
||||
user = self.make_stock_user()
|
||||
receipt = make_purchase_receipt(item_code=item.name, qty=1, do_not_save=True)
|
||||
receipt.items[0].serial_no = "UNAUTHORIZED-ON-SAVE"
|
||||
receipt.items[0].set("__serial_batch_input", ["serial_no"])
|
||||
with self.set_user(user.name):
|
||||
with self.assertRaises(frappe.PermissionError):
|
||||
receipt.insert()
|
||||
self.assertFalse(frappe.db.exists("Serial No", {"item_code": item.name}))
|
||||
|
||||
def test_bundle_number_input_checks_creation_permissions(self):
|
||||
from erpnext.stock.serial_batch_identity import resolve_number_entries
|
||||
|
||||
item = make_item(properties={"has_serial_no": 1})
|
||||
serial = SerialBatchIdentity("Serial No").resolve(item.name, ["Existing"], create=True)[0]
|
||||
user = self.make_stock_user()
|
||||
with self.set_user(user.name):
|
||||
entries = [{"serial_number": "Existing"}]
|
||||
resolve_number_entries(item.name, entries, create=True)
|
||||
self.assertEqual(entries[0]["serial_no"], serial)
|
||||
with self.assertRaises(frappe.PermissionError):
|
||||
resolve_number_entries(item.name, [{"serial_number": "Unauthorized"}], create=True)
|
||||
self.assertFalse(SerialBatchIdentity("Serial No").exists("Unauthorized", item.name))
|
||||
|
||||
def test_bundle_scanning_does_not_create_records(self):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
resolve_scanned_serial_batch_numbers,
|
||||
)
|
||||
|
||||
item = make_item(properties={"has_serial_no": 1})
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
resolve_scanned_serial_batch_numbers(item.name, serial_no="Missing")
|
||||
self.assertFalse(frappe.db.exists("Serial No", {"item_code": item.name}))
|
||||
130
erpnext/stock/tests/test_serial_batch_identity_matching.py
Normal file
130
erpnext/stock/tests/test_serial_batch_identity_matching.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity, validate_item_merge
|
||||
from erpnext.stock.utils import scan_barcode
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSerialBatchIdentityMatching(ERPNextTestSuite):
|
||||
def make_number(self, doctype, number):
|
||||
identity = SerialBatchIdentity(doctype)
|
||||
item = make_item(
|
||||
properties={"has_serial_no": int(doctype == "Serial No"), "has_batch_no": int(doctype == "Batch")}
|
||||
)
|
||||
name = identity.resolve(item.name, [number], create=True, defaults={"company": "_Test Company"})[0]
|
||||
return identity, item, name
|
||||
|
||||
def test_case_insensitive_resolution_preserves_physical_label(self):
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
identity, item, name = self.make_number(doctype, "Mixed-Lot-001")
|
||||
for number in ("mixed-lot-001", "MIXED-LOT-001"):
|
||||
self.assertEqual(identity.resolve(item.name, [number]), [name])
|
||||
self.assertEqual(identity.resolve(item.name, [number], create=True), [name])
|
||||
self.assertEqual(identity.labels([name]), {name: "Mixed-Lot-001"})
|
||||
|
||||
def test_case_insensitive_scan_keeps_items_separate(self):
|
||||
for doctype, field in (("Serial No", "serial_no"), ("Batch", "batch_no")):
|
||||
number = "SCAN-" + frappe.generate_hash().upper()
|
||||
_, item_a, name_a = self.make_number(doctype, number)
|
||||
_, item_b, name_b = self.make_number(doctype, number.lower())
|
||||
matches = scan_barcode(number.swapcase(), allow_multiple=True)["candidates"]
|
||||
self.assertEqual({match[field] for match in matches}, {name_a, name_b})
|
||||
for item, name in ((item_a, name_a), (item_b, name_b)):
|
||||
self.assertEqual(scan_barcode(number.swapcase(), {"item_code": item.name})[field], name)
|
||||
|
||||
def test_controller_rejects_case_variant_for_same_item(self):
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
identity, _, name = self.make_number(doctype, "Mixed-Lot-001")
|
||||
duplicate = frappe.copy_doc(frappe.get_doc(doctype, name))
|
||||
duplicate.set(identity.number_field, "MIXED-LOT-001")
|
||||
with self.assertRaises(frappe.DuplicateEntryError):
|
||||
duplicate.insert()
|
||||
|
||||
def test_database_rejects_case_variant_without_controller_validation(self):
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
identity, _, name = self.make_number(doctype, "Mixed-Lot-001")
|
||||
duplicate = frappe.get_doc(doctype, name)
|
||||
duplicate.name = frappe.generate_hash()
|
||||
duplicate.set(identity.number_field, "MIXED-LOT-001")
|
||||
frappe.db.savepoint("case_variant")
|
||||
try:
|
||||
with self.assertRaises((frappe.DuplicateEntryError, frappe.UniqueValidationError)):
|
||||
duplicate.db_insert()
|
||||
finally:
|
||||
frappe.db.rollback(save_point="case_variant")
|
||||
|
||||
def test_item_merge_rejects_case_variants(self):
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
_, item_a, _ = self.make_number(doctype, "Mixed-Lot-001")
|
||||
_, item_b, _ = self.make_number(doctype, "MIXED-LOT-001")
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
validate_item_merge(item_a.name, item_b.name)
|
||||
|
||||
def test_generated_numbers_skip_case_variant_collisions(self):
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_new_serial_number
|
||||
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
prefix = "CASE-" + frappe.generate_hash().upper() + "-"
|
||||
_, item, _ = self.make_number(doctype, prefix.lower() + "00001")
|
||||
series = prefix + ".#####"
|
||||
if doctype == "Serial No":
|
||||
number = get_new_serial_number(series, item.name)
|
||||
else:
|
||||
item.create_new_batch = 1
|
||||
item.batch_number_series = series
|
||||
item.save()
|
||||
number = frappe.get_doc({"doctype": "Batch", "item": item.name}).insert().batch_id
|
||||
self.assertEqual(number, prefix + "00002")
|
||||
|
||||
def test_create_case_variants_in_one_request(self):
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
identity, item, existing = self.make_number(doctype, "Existing-Lot")
|
||||
numbers = ["New-Lot", "NEW-LOT", "Second-Lot", "new-lot", "existing-lot"]
|
||||
names = identity.resolve(item.name, numbers, create=True)
|
||||
self.assertEqual(names[0], names[1])
|
||||
self.assertEqual(names[0], names[3])
|
||||
self.assertNotEqual(names[0], names[2])
|
||||
self.assertEqual(names[4], existing)
|
||||
self.assertEqual(identity.resolve(item.name, numbers), names)
|
||||
self.assertEqual(identity.labels(names)[names[0]], "New-Lot")
|
||||
self.assertEqual(frappe.db.count(doctype, {identity.item_field: item.name}), 3)
|
||||
|
||||
def test_create_aliases_uses_mariadb_collation(self):
|
||||
if frappe.db.db_type != "mariadb":
|
||||
self.skipTest("MariaDB's accent-insensitive collation")
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
identity, item, _ = self.make_number(doctype, "Existing-Lot")
|
||||
names = identity.resolve(item.name, ["Café-Lot", "Cafe-Lot"], create=True)
|
||||
self.assertEqual(names[0], names[1])
|
||||
self.assertEqual(identity.labels(names)[names[0]], "Café-Lot")
|
||||
|
||||
def test_migration_reports_case_conflicts_before_changing_records(self):
|
||||
if frappe.db.db_type != "postgres":
|
||||
self.skipTest("Legacy case-only duplicates are possible on PostgreSQL")
|
||||
from erpnext.patches.v17_0.separate_serial_batch_identity import execute
|
||||
|
||||
for doctype, index in (("Serial No", "serial_no_number_item_ci"), ("Batch", "batch_number_item_ci")):
|
||||
identity, item, name = self.make_number(doctype, "Legacy-Lot")
|
||||
frappe.db.savepoint("legacy_case_conflict")
|
||||
try:
|
||||
# PostgreSQL DDL is transactional, so rollback restores the unique index.
|
||||
frappe.db.sql(f'DROP INDEX "{index}"')
|
||||
duplicate = frappe.get_doc(doctype, name)
|
||||
duplicate.name = frappe.generate_hash()
|
||||
duplicate.set(identity.number_field, "LEGACY-LOT")
|
||||
duplicate.db_insert()
|
||||
with patch("frappe.reload_doc") as reload_doc:
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
execute()
|
||||
reload_doc.assert_not_called()
|
||||
for value in (item.name, name, duplicate.name):
|
||||
self.assertIn(value, str(error.exception))
|
||||
self.assertEqual(
|
||||
identity.labels([name, duplicate.name]),
|
||||
{name: "Legacy-Lot", duplicate.name: "LEGACY-LOT"},
|
||||
)
|
||||
finally:
|
||||
frappe.db.rollback(save_point="legacy_case_conflict")
|
||||
321
erpnext/stock/tests/test_serial_batch_input.py
Normal file
321
erpnext/stock/tests/test_serial_batch_input.py
Normal file
@@ -0,0 +1,321 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSerialBatchInput(ERPNextTestSuite):
|
||||
def make_receipt(self, **properties):
|
||||
item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1, **properties})
|
||||
return make_purchase_receipt(item_code=item.name, qty=1, rate=100, do_not_save=True)
|
||||
|
||||
def test_physical_input_is_created_on_save_and_used_on_submit(self):
|
||||
ids = []
|
||||
for _ in range(2):
|
||||
receipt = self.make_receipt()
|
||||
row = receipt.items[0]
|
||||
row.serial_no, row.batch_no = "Physical-Serial", "Physical-Batch"
|
||||
row.set("__serial_batch_input", ["serial_no", "batch_no"])
|
||||
self.assertFalse(frappe.db.exists("Serial No", {"item_code": row.item_code}))
|
||||
receipt.insert()
|
||||
self.assertFalse(row.get("__serial_batch_input"))
|
||||
self.assertNotIn("__serial_batch_input", receipt.as_dict()["items"][0])
|
||||
self.assertNotEqual(row.serial_no, "Physical-Serial")
|
||||
self.assertNotEqual(row.batch_no, "Physical-Batch")
|
||||
self.assertEqual(frappe.get_doc("Serial No", row.serial_no).status, "Inactive")
|
||||
ids.append(row.serial_no)
|
||||
receipt.submit()
|
||||
entry = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle).entries[0]
|
||||
self.assertEqual(entry.serial_no, ids[-1])
|
||||
self.assertEqual(frappe.get_doc("Serial No", ids[-1]).serial_no, "Physical-Serial")
|
||||
receipt.cancel()
|
||||
self.assertNotEqual(*ids)
|
||||
|
||||
def test_failed_save_rolls_back_number_creation(self):
|
||||
receipt = self.make_receipt()
|
||||
row = receipt.items[0]
|
||||
row.serial_no, row.batch_no = "Rollback-Serial", "Rollback-Batch"
|
||||
row.set("__serial_batch_input", ["serial_no", "batch_no"])
|
||||
frappe.db.savepoint("failed_physical_input")
|
||||
try:
|
||||
with patch.object(type(receipt), "validate", side_effect=frappe.ValidationError):
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
receipt.insert()
|
||||
finally:
|
||||
frappe.db.rollback(save_point="failed_physical_input")
|
||||
self.assertFalse(frappe.db.exists("Serial No", {"item_code": row.item_code}))
|
||||
self.assertFalse(frappe.db.exists("Batch", {"item": row.item_code}))
|
||||
|
||||
def test_auto_numbering_still_runs_on_submit(self):
|
||||
receipt = self.make_receipt(
|
||||
serial_no_series="AUTO-" + frappe.generate_hash() + "-.#####", create_new_batch=1
|
||||
)
|
||||
receipt.insert()
|
||||
item = receipt.items[0].item_code
|
||||
self.assertFalse(frappe.db.exists("Serial No", {"item_code": item}))
|
||||
receipt.submit()
|
||||
receipt.reload()
|
||||
bundle = frappe.get_doc("Serial and Batch Bundle", receipt.items[0].serial_and_batch_bundle)
|
||||
self.assertEqual(len(bundle.entries), 1)
|
||||
self.assertEqual(frappe.get_doc("Serial No", bundle.entries[0].serial_no).status, "Active")
|
||||
receipt.cancel()
|
||||
|
||||
def test_series_collision_explains_how_to_fix_it(self):
|
||||
prefix = "COLLISION-" + frappe.generate_hash() + "-"
|
||||
receipt = self.make_receipt(has_batch_no=0, serial_no_series=prefix + ".#####")
|
||||
item = receipt.items[0].item_code
|
||||
SerialBatchIdentity("Serial No").resolve(item, [prefix + "00001"], create=True)
|
||||
receipt.insert()
|
||||
frappe.db.savepoint("series_collision")
|
||||
try:
|
||||
with self.assertRaises(frappe.DuplicateEntryError) as error:
|
||||
receipt.submit()
|
||||
self.assertIn("Serial No Series", str(error.exception))
|
||||
self.assertIn(item, str(error.exception))
|
||||
finally:
|
||||
frappe.db.rollback(save_point="series_collision")
|
||||
|
||||
def test_existing_constraints_skip_data_scans(self):
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
identity = SerialBatchIdentity(doctype)
|
||||
self.assertTrue(identity.has_constraint())
|
||||
with patch.object(identity, "validate_existing_numbers") as validate:
|
||||
with patch.object(identity, "backfill_numbers") as backfill:
|
||||
identity.sync_constraint()
|
||||
validate.assert_not_called()
|
||||
backfill.assert_not_called()
|
||||
|
||||
def test_duplicate_physical_serials_are_rejected(self):
|
||||
receipt = self.make_receipt(has_batch_no=0)
|
||||
receipt.items[0].serial_no = "Same-Serial\nSAME-SERIAL"
|
||||
receipt.items[0].set("__serial_batch_input", ["serial_no"])
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
receipt.insert()
|
||||
|
||||
def test_physical_input_never_falls_back_to_an_internal_id(self):
|
||||
receipt = self.make_receipt(has_batch_no=0)
|
||||
row = receipt.items[0]
|
||||
identity = SerialBatchIdentity("Serial No")
|
||||
original = identity.resolve(row.item_code, ["One"], create=True)[0]
|
||||
other = identity.resolve(row.item_code, [original], create=True)[0]
|
||||
row.serial_no = original
|
||||
row.set("__serial_batch_input", ["serial_no"])
|
||||
receipt.insert()
|
||||
self.assertEqual(row.serial_no, other)
|
||||
receipt.save()
|
||||
self.assertEqual(row.serial_no, other)
|
||||
receipt.reload()
|
||||
self.assertEqual(row.serial_no, other)
|
||||
|
||||
def test_unmarked_fields_preserve_internal_ids(self):
|
||||
receipt = self.make_receipt()
|
||||
row = receipt.items[0]
|
||||
row.serial_no = SerialBatchIdentity("Serial No").resolve(row.item_code, ["One"], create=True)[0]
|
||||
row.batch_no = SerialBatchIdentity("Batch").resolve(row.item_code, ["One"], create=True)[0]
|
||||
ids = row.serial_no, row.batch_no
|
||||
receipt.insert()
|
||||
self.assertEqual((row.serial_no, row.batch_no), ids)
|
||||
|
||||
def test_request_metadata_survives_serialization_until_save(self):
|
||||
receipt = self.make_receipt()
|
||||
row = receipt.items[0]
|
||||
row.serial_no, row.batch_no = "API-Serial", "API-Batch"
|
||||
row.set("__serial_batch_input", ["serial_no", "batch_no"])
|
||||
payload = frappe.parse_json(receipt.as_json())
|
||||
self.assertEqual(payload["items"][0]["__serial_batch_input"], ["serial_no", "batch_no"])
|
||||
saved = frappe.get_doc(payload).insert()
|
||||
self.assertEqual(frappe.get_doc("Serial No", saved.items[0].serial_no).serial_no, "API-Serial")
|
||||
self.assertEqual(frappe.get_doc("Batch", saved.items[0].batch_no).batch_id, "API-Batch")
|
||||
self.assertNotIn("__serial_batch_input", saved.as_dict()["items"][0])
|
||||
|
||||
def test_input_metadata_cannot_target_other_fields(self):
|
||||
receipt = self.make_receipt()
|
||||
receipt.items[0].set("__serial_batch_input", ["item_code"])
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
receipt.insert()
|
||||
|
||||
def test_no_extra_transaction_number_fields(self):
|
||||
for doctype in frappe.get_all(
|
||||
"DocField", filters={"fieldname": "serial_and_batch_bundle"}, pluck="parent", distinct=True
|
||||
):
|
||||
meta = frappe.get_meta(doctype)
|
||||
for field in ("serial_number", "batch_number", "rejected_serial_number", "current_serial_number"):
|
||||
self.assertFalse(meta.has_field(field), (doctype, field))
|
||||
|
||||
def test_input_requires_a_bundle_field(self):
|
||||
from erpnext.stock.serial_batch_input import resolve_transaction_numbers
|
||||
|
||||
receipt = self.make_receipt()
|
||||
row = receipt.items[0]
|
||||
row.serial_no = "Pending-Serial"
|
||||
row.set("__serial_batch_input", ["serial_no"])
|
||||
with patch.object(row.meta, "has_field", return_value=False):
|
||||
resolve_transaction_numbers(receipt)
|
||||
self.assertEqual(row.serial_no, "Pending-Serial")
|
||||
self.assertNotIn("__serial_batch_input", receipt.as_dict(no_private_properties=True)["items"][0])
|
||||
self.assertFalse(frappe.db.exists("Serial No", {"item_code": row.item_code}))
|
||||
|
||||
def test_number_inputs_follow_child_field_metadata(self):
|
||||
from erpnext.stock.serial_batch_import import has_number_inputs
|
||||
from erpnext.stock.serial_batch_input import resolve_transaction_numbers
|
||||
|
||||
doctype = "Installation Note Item"
|
||||
meta = frappe.get_meta(doctype)
|
||||
with patch.dict(
|
||||
meta._fields,
|
||||
{field: df for field, df in meta._fields.items() if field != "serial_and_batch_bundle"},
|
||||
clear=True,
|
||||
):
|
||||
self.assertFalse(has_number_inputs("Installation Note"))
|
||||
meta._fields["serial_and_batch_bundle"] = frappe._dict(
|
||||
fieldname="serial_and_batch_bundle", fieldtype="Data"
|
||||
)
|
||||
self.assertTrue(has_number_inputs("Installation Note"))
|
||||
receipt = self.make_receipt(has_batch_no=0)
|
||||
item = receipt.items[0].item_code
|
||||
serial = SerialBatchIdentity("Serial No").resolve(item, ["Custom-Serial"], create=True)[0]
|
||||
doc = frappe.get_doc(doctype="Installation Note", items=[{"item_code": item}])
|
||||
row = doc.items[0]
|
||||
row.serial_no = "Custom-Serial"
|
||||
row.set("__serial_batch_input", ["serial_no"])
|
||||
payload = frappe.parse_json(doc.as_json())
|
||||
self.assertEqual(payload["items"][0]["__serial_batch_input"], ["serial_no"])
|
||||
resolve_transaction_numbers(doc)
|
||||
self.assertEqual(row.serial_no, serial)
|
||||
|
||||
def test_bundle_save_resolves_physical_entries(self):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
add_serial_batch_ledgers,
|
||||
)
|
||||
|
||||
receipt = self.make_receipt()
|
||||
receipt.insert()
|
||||
row = receipt.items[0]
|
||||
bundle = add_serial_batch_ledgers(
|
||||
entries=[{"serial_number": "Bundle-Serial", "batch_number": "Bundle-Batch", "qty": 1}],
|
||||
child_row=row.as_dict(),
|
||||
doc=receipt.as_dict(),
|
||||
)
|
||||
entry = bundle.entries[0]
|
||||
self.assertNotEqual(entry.serial_no, "Bundle-Serial")
|
||||
self.assertNotEqual(entry.batch_no, "Bundle-Batch")
|
||||
self.assertEqual(frappe.get_doc("Serial No", entry.serial_no).serial_no, "Bundle-Serial")
|
||||
self.assertEqual(frappe.get_doc("Batch", entry.batch_no).batch_id, "Bundle-Batch")
|
||||
|
||||
def test_bundle_entry_preserves_an_explicit_batch(self):
|
||||
from erpnext.stock.serial_batch_identity import resolve_number_entries
|
||||
|
||||
receipt = self.make_receipt()
|
||||
item = receipt.items[0].item_code
|
||||
batch, other_batch = SerialBatchIdentity("Batch").resolve(item, ["One", "Two"], create=True)
|
||||
serial = SerialBatchIdentity("Serial No").resolve(
|
||||
item, ["Bundled"], create=True, defaults={"batch_no": batch}
|
||||
)[0]
|
||||
entries = [{"serial_no": serial}, {"serial_no": serial, "batch_no": other_batch}]
|
||||
resolve_number_entries(item, entries)
|
||||
self.assertEqual(entries[0]["batch_no"], batch)
|
||||
self.assertEqual(entries[1]["batch_no"], other_batch)
|
||||
|
||||
def test_data_import_can_explicitly_preserve_internal_ids(self):
|
||||
from erpnext.stock.serial_batch_import import SerialBatchImporter
|
||||
|
||||
receipt = self.make_receipt()
|
||||
item = receipt.items[0].item_code
|
||||
serial = SerialBatchIdentity("Serial No").resolve(item, ["Exported-Serial"], create=True)[0]
|
||||
batch = SerialBatchIdentity("Batch").resolve(item, ["Exported-Batch"], create=True)[0]
|
||||
with TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "internal_ids.csv"
|
||||
with path.open("w", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(
|
||||
["supplier", "company", "items.item_code", "items.serial_no", "items.batch_no"]
|
||||
)
|
||||
writer.writerow([receipt.supplier, receipt.company, item, serial, batch])
|
||||
importer = SerialBatchImporter(
|
||||
"Purchase Receipt",
|
||||
file_path=str(path),
|
||||
console=True,
|
||||
data_import=frappe.get_doc(
|
||||
doctype="Data Import",
|
||||
import_type="Insert New Records",
|
||||
template_options=frappe.as_json({"column_to_field_map": {}, "serial_batch_input": False}),
|
||||
),
|
||||
)
|
||||
row = importer.import_file.get_payloads_for_import()[0].doc["items"][0]
|
||||
self.assertFalse(row.get("__serial_batch_input"))
|
||||
self.assertEqual((row.serial_no, row.batch_no), (serial, batch))
|
||||
|
||||
def test_data_import_reuses_existing_number_columns(self):
|
||||
from erpnext.stock.serial_batch_import import SerialBatchImporter
|
||||
|
||||
receipt = self.make_receipt()
|
||||
with TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "physical_numbers.csv"
|
||||
with path.open("w", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(
|
||||
[
|
||||
"supplier",
|
||||
"company",
|
||||
"items.item_code",
|
||||
"items.qty",
|
||||
"items.rate",
|
||||
"items.warehouse",
|
||||
"items.serial_no",
|
||||
"items.batch_no",
|
||||
]
|
||||
)
|
||||
writer.writerow(
|
||||
[
|
||||
receipt.supplier,
|
||||
receipt.company,
|
||||
receipt.items[0].item_code,
|
||||
1,
|
||||
100,
|
||||
receipt.items[0].warehouse,
|
||||
"Imported-Serial",
|
||||
"Imported-Batch",
|
||||
]
|
||||
)
|
||||
importer = SerialBatchImporter(
|
||||
"Purchase Receipt",
|
||||
file_path=str(path),
|
||||
import_type="Insert New Records",
|
||||
console=True,
|
||||
data_import=frappe.get_doc(doctype="Data Import", import_type="Insert New Records"),
|
||||
)
|
||||
payloads = importer.import_file.get_payloads_for_import()
|
||||
self.assertFalse(importer.import_file.get_all_warnings())
|
||||
file = frappe.get_doc(
|
||||
doctype="File", file_name="physical_numbers.csv", content=path.read_text(), is_private=1
|
||||
).insert()
|
||||
self.addCleanup(frappe.delete_doc, "File", file.name)
|
||||
self.assertEqual(len(payloads), 1)
|
||||
data_import = frappe.get_doc(
|
||||
doctype="Data Import",
|
||||
reference_doctype="Purchase Receipt",
|
||||
import_type="Insert New Records",
|
||||
import_file=file.file_url,
|
||||
submit_after_import=1,
|
||||
).insert()
|
||||
self.assertIsInstance(data_import.get_importer(), SerialBatchImporter)
|
||||
with patch.object(frappe.db, "commit"):
|
||||
data_import.start_import()
|
||||
self.assertEqual(data_import.reload().status, "Success")
|
||||
imported = frappe.get_doc(
|
||||
"Purchase Receipt",
|
||||
frappe.db.get_value("Data Import Log", {"data_import": data_import.name}, "docname"),
|
||||
)
|
||||
self.assertEqual(imported.docstatus, 1)
|
||||
row = imported.items[0]
|
||||
entry = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle).entries[0]
|
||||
self.assertEqual(frappe.get_doc("Serial No", entry.serial_no).serial_no, "Imported-Serial")
|
||||
self.assertEqual(frappe.get_doc("Batch", entry.batch_no).batch_id, "Imported-Batch")
|
||||
imported.cancel()
|
||||
166
erpnext/stock/tests/test_serial_batch_messages.py
Normal file
166
erpnext/stock/tests/test_serial_batch_messages.py
Normal file
@@ -0,0 +1,166 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, escape_html, now_datetime, today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
BatchNegativeStockError,
|
||||
SerialNoDuplicateError,
|
||||
SerialNoExistsInFutureTransactionError,
|
||||
SerialNoWarehouseError,
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import throw_negative_batch_validation
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSerialBatchMessages(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1})
|
||||
self.batch_number = "Batch<&>"
|
||||
self.serial_number = "Serial<&>"
|
||||
self.batch = SerialBatchIdentity("Batch").resolve(self.item.name, [self.batch_number], create=True)[0]
|
||||
self.serial = SerialBatchIdentity("Serial No").resolve(
|
||||
self.item.name,
|
||||
[self.serial_number],
|
||||
create=True,
|
||||
defaults={"company": "_Test Company", "batch_no": self.batch},
|
||||
)[0]
|
||||
self.bundle = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial and Batch Bundle",
|
||||
"item_code": self.item.name,
|
||||
"has_serial_no": 1,
|
||||
"has_batch_no": 1,
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"type_of_transaction": "Inward",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"entries": [{"serial_no": self.serial, "batch_no": self.batch, "qty": 1}],
|
||||
}
|
||||
)
|
||||
|
||||
def test_duplicate_receipt_shows_serial_number(self):
|
||||
args = {
|
||||
"item_code": self.item.name,
|
||||
"qty": 1,
|
||||
"serial_no": self.serial,
|
||||
"batch_no": self.batch,
|
||||
"use_serial_batch_fields": 1,
|
||||
}
|
||||
make_purchase_receipt(**args)
|
||||
receipt = make_purchase_receipt(**args, do_not_submit=True)
|
||||
with self.assertRaises(SerialNoDuplicateError) as error:
|
||||
receipt.submit()
|
||||
self.assert_number_message(error, self.serial, self.serial_number)
|
||||
self.assertIn("already present in the warehouse", str(error.exception))
|
||||
self.assertEqual(receipt.items[0].serial_no, self.serial)
|
||||
self.assertEqual(frappe.db.get_value("Serial No", self.serial, "warehouse"), self.bundle.warehouse)
|
||||
|
||||
def test_missing_serial_inventory_shows_number(self):
|
||||
self.bundle.type_of_transaction = "Outward"
|
||||
with self.assertRaises(SerialNoWarehouseError) as error:
|
||||
self.bundle.validate_serial_nos_inventory()
|
||||
self.assert_number_message(error, self.serial, self.serial_number)
|
||||
|
||||
def test_duplicate_entries_show_numbers(self):
|
||||
for doctype, field, name, number in self.number_cases():
|
||||
with self.subTest(doctype=doctype):
|
||||
self.bundle.set("entries", [{field: name, "qty": 1}, {field: name, "qty": 1}])
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
self.bundle.validate_duplicate_serial_and_batch_no()
|
||||
self.assert_number_message(error, name, number)
|
||||
self.assertEqual([row.get(field) for row in self.bundle.entries], [name, name])
|
||||
|
||||
def test_wrong_item_shows_numbers(self):
|
||||
self.bundle.item_code = make_item().name
|
||||
for doctype, _field, name, number in self.number_cases():
|
||||
with self.subTest(doctype=doctype):
|
||||
validate = (
|
||||
self.bundle.validate_incorrect_serial_nos
|
||||
if doctype == "Serial No"
|
||||
else self.bundle.validate_incorrect_batch_nos
|
||||
)
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
validate([name])
|
||||
self.assert_number_message(error, name, number)
|
||||
|
||||
def test_return_error_shows_numbers(self):
|
||||
for doctype, field, name, number in self.number_cases():
|
||||
with self.subTest(doctype=doctype):
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
self.bundle.validate_returned_serial_batch_no(
|
||||
"Original Receipt", frappe._dict({field: name}), {"serial_nos": [], "batches": []}
|
||||
)
|
||||
self.assert_number_message(error, name, number)
|
||||
|
||||
def test_negative_stock_shows_batch_number(self):
|
||||
with self.assertRaises(BatchNegativeStockError) as error:
|
||||
self.bundle.validate_negative_batch(self.batch, -1)
|
||||
self.assert_number_message(error, self.batch, self.batch_number)
|
||||
|
||||
def test_expired_batch_shows_number(self):
|
||||
frappe.db.set_value("Batch", self.batch, "expiry_date", add_days(today(), -1))
|
||||
entry = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Ledger Entry",
|
||||
"batch_no": self.batch,
|
||||
"item_code": self.item.name,
|
||||
"voucher_type": "Delivery Note",
|
||||
"actual_qty": -1,
|
||||
"posting_date": today(),
|
||||
}
|
||||
)
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
entry.validate_batch()
|
||||
self.assert_number_message(error, self.batch, self.batch_number)
|
||||
self.assertEqual(entry.batch_no, self.batch)
|
||||
|
||||
def test_serial_batch_mismatch_shows_both_numbers(self):
|
||||
batch_number = "Other Batch<&>"
|
||||
batch = SerialBatchIdentity("Batch").resolve(self.item.name, [batch_number], create=True)[0]
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
self.bundle.validate_serial_batch_no({self.serial: batch})
|
||||
self.assert_number_message(error, self.serial, self.serial_number)
|
||||
self.assert_number_message(error, batch, batch_number)
|
||||
|
||||
def test_future_transaction_shows_serial_number_and_document_link(self):
|
||||
receipt = make_purchase_receipt(
|
||||
item_code=self.item.name, qty=1, serial_no=[self.serial], batch_no=self.batch
|
||||
)
|
||||
self.bundle.name = "new-bundle"
|
||||
self.bundle.posting_datetime = add_days(now_datetime(), -1)
|
||||
with self.assertRaises(SerialNoExistsInFutureTransactionError) as error:
|
||||
self.bundle.check_future_entries_exists()
|
||||
self.assert_number_message(error, self.serial, self.serial_number)
|
||||
self.assertIn(f'/purchase-receipt/{receipt.name}"', str(error.exception))
|
||||
|
||||
def test_legacy_and_missing_records_keep_the_number(self):
|
||||
frappe.db.set_value("Serial No", self.serial, "serial_no", self.serial)
|
||||
for name in (self.serial, "Missing<&>"):
|
||||
with self.subTest(name=name):
|
||||
self.bundle.set("entries", [{"serial_no": name}, {"serial_no": name}])
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
self.bundle.validate_duplicate_serial_and_batch_no()
|
||||
self.assertIn(escape_html(name), str(error.exception))
|
||||
|
||||
def test_batch_error_link_keeps_id_and_displays_number(self):
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
throw_negative_batch_validation(self.batch, -1)
|
||||
message = str(error.exception)
|
||||
self.assertIn(f'/batch/{self.batch}"', message)
|
||||
self.assertIn(f">{escape_html(self.batch_number)}</a>", message)
|
||||
self.assertNotIn(f">{self.batch}</a>", message)
|
||||
self.assertNotIn(self.batch_number, message)
|
||||
|
||||
def number_cases(self):
|
||||
return [
|
||||
("Serial No", "serial_no", self.serial, self.serial_number),
|
||||
("Batch", "batch_no", self.batch, self.batch_number),
|
||||
]
|
||||
|
||||
def assert_number_message(self, error, name, number):
|
||||
message = str(error.exception)
|
||||
self.assertIn(escape_html(number), message)
|
||||
self.assertNotIn(name, message)
|
||||
self.assertNotIn(number, message)
|
||||
122
erpnext/stock/tests/test_serial_batch_number_lookup.py
Normal file
122
erpnext/stock/tests/test_serial_batch_number_lookup.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity, resolve_number_entries
|
||||
from erpnext.stock.serial_batch_input import TransactionNumberInput
|
||||
from erpnext.stock.serial_batch_number_lookup import SerialBatchNumberLookup
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSerialBatchNumberLookup(ERPNextTestSuite):
|
||||
def make_item_and_identity(self, doctype):
|
||||
item = make_item(properties={"has_serial_no": 1, "has_batch_no": int(doctype == "Batch")})
|
||||
identity = SerialBatchIdentity(doctype)
|
||||
identity.resolve(item.name, ["Existing"], create=True)
|
||||
return item, identity
|
||||
|
||||
def test_existing_number_aliases_use_one_query(self):
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
item, identity = self.make_item_and_identity(doctype)
|
||||
numbers = [f"Number-{index:03d}" for index in range(50)]
|
||||
ids = identity.resolve(item.name, numbers, create=True)
|
||||
requested = [number.swapcase() for number in reversed(numbers)] + [numbers[0].upper()]
|
||||
with self.assert_select_query_count(1):
|
||||
resolved = identity.resolve(item.name, requested, create=True)
|
||||
self.assertEqual(resolved, [*reversed(ids), ids[0]])
|
||||
|
||||
def test_new_serial_aliases_share_one_lookup_and_bulk_insert(self):
|
||||
item, identity = self.make_item_and_identity("Serial No")
|
||||
numbers = [f"New-{index:03d}" for index in range(100)]
|
||||
with patch.object(frappe.db, "bulk_insert", wraps=frappe.db.bulk_insert) as insert:
|
||||
with self.assert_select_query_count(1):
|
||||
ids = identity.resolve(
|
||||
item.name, numbers + [number.upper() for number in numbers], create=True
|
||||
)
|
||||
insert.assert_called_once()
|
||||
self.assertEqual(ids[:100], ids[100:])
|
||||
self.assertEqual(len(set(ids)), 100)
|
||||
self.assertEqual(identity.labels([ids[0]]), {ids[0]: numbers[0]})
|
||||
|
||||
def test_duplicate_retry_remains_batched(self):
|
||||
item, identity = self.make_item_and_identity("Serial No")
|
||||
numbers = [f"Retry-{index:03d}" for index in range(50)]
|
||||
create_many = identity.create_many
|
||||
attempts = []
|
||||
|
||||
def create(item_code, requested, defaults):
|
||||
attempts.append(requested)
|
||||
if len(attempts) == 1:
|
||||
raise frappe.DuplicateEntryError
|
||||
return create_many(item_code, requested, defaults)
|
||||
|
||||
with patch.object(identity, "create_many", side_effect=create):
|
||||
with self.assert_select_query_count(2):
|
||||
ids = identity.resolve(
|
||||
item.name, numbers + [number.upper() for number in numbers], create=True
|
||||
)
|
||||
self.assertEqual(attempts, [numbers, numbers])
|
||||
self.assertEqual(ids[:50], ids[50:])
|
||||
|
||||
def test_batch_creation_keeps_lifecycle_validation(self):
|
||||
item = make_item(properties={"has_batch_no": 1, "has_expiry_date": 1, "shelf_life_in_days": 30})
|
||||
identity = SerialBatchIdentity("Batch")
|
||||
with patch.object(SerialBatchIdentity, "exists", side_effect=AssertionError("Per-number lookup")):
|
||||
names = identity.resolve(
|
||||
item.name,
|
||||
["Batch-One", "BATCH-ONE", "Batch-Two"],
|
||||
create=True,
|
||||
defaults={"manufacturing_date": today()},
|
||||
)
|
||||
self.assertEqual(names[0], names[1])
|
||||
self.assertNotEqual(names[0], names[2])
|
||||
batch = frappe.get_doc("Batch", names[0])
|
||||
self.assertEqual(getdate(batch.expiry_date), getdate(add_days(today(), 30)))
|
||||
self.assertEqual(batch.use_batchwise_valuation, 1)
|
||||
|
||||
def test_prechecked_batch_still_has_database_uniqueness(self):
|
||||
item, identity = self.make_item_and_identity("Batch")
|
||||
frappe.db.savepoint("prechecked_batch")
|
||||
try:
|
||||
with self.assertRaises((frappe.DuplicateEntryError, frappe.UniqueValidationError)):
|
||||
identity.create_batch(item.name, "EXISTING")
|
||||
finally:
|
||||
frappe.db.rollback(save_point="prechecked_batch")
|
||||
|
||||
def test_transaction_and_bundle_resolution_reuse_the_lookup(self):
|
||||
item, identity = self.make_item_and_identity("Serial No")
|
||||
numbers = [f"Selected-{index:03d}" for index in range(50)]
|
||||
names = identity.resolve(item.name, numbers, create=True)
|
||||
receipt = make_purchase_receipt(item_code=item.name, qty=50, do_not_save=True)
|
||||
row = receipt.items[0]
|
||||
row.serial_no = "\n".join(number.lower() for number in numbers)
|
||||
row.set("__serial_batch_input", ["serial_no"])
|
||||
load = SerialBatchNumberLookup.load
|
||||
with patch.object(SerialBatchNumberLookup, "load", autospec=True, side_effect=load) as lookup:
|
||||
TransactionNumberInput(receipt, row).resolve()
|
||||
lookup.assert_called_once()
|
||||
self.assertEqual(row.serial_no.splitlines(), names)
|
||||
entries = [{"serial_number": number.lower()} for number in numbers]
|
||||
with patch.object(SerialBatchNumberLookup, "load", autospec=True, side_effect=load) as lookup:
|
||||
resolve_number_entries(item.name, entries, create=True)
|
||||
lookup.assert_called_once()
|
||||
self.assertEqual([entry["serial_no"] for entry in entries], names)
|
||||
|
||||
def test_bound_numbers_do_not_become_sql(self):
|
||||
item, identity = self.make_item_and_identity("Serial No")
|
||||
numbers = ["Serial'One", "100%_Matched", 'A"B']
|
||||
names = identity.resolve(item.name, numbers, create=True)
|
||||
self.assertEqual(identity.resolve(item.name, numbers), names)
|
||||
self.assertEqual(identity.labels(names), dict(zip(names, numbers, strict=True)))
|
||||
|
||||
@contextmanager
|
||||
def assert_select_query_count(self, count):
|
||||
with patch.object(frappe.db, "sql", wraps=frappe.db.sql) as sql:
|
||||
yield
|
||||
queries = [str(call.args[0]) for call in sql.call_args_list]
|
||||
selects = [query for query in queries if query.lstrip().lower().startswith("select")]
|
||||
self.assertEqual(len(selects), count, "\n".join(selects))
|
||||
156
erpnext/stock/tests/test_serial_batch_print.py
Normal file
156
erpnext/stock/tests/test_serial_batch_print.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from bs4 import BeautifulSoup
|
||||
from frappe.utils import escape_html
|
||||
from frappe.utils.print_format_generator import PrintFormatGenerator
|
||||
from frappe.www.printview import set_link_titles
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSerialBatchPrint(ERPNextTestSuite):
|
||||
def make_print(self, number="PRINT-SERIAL"):
|
||||
item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1})
|
||||
serial = SerialBatchIdentity("Serial No").resolve(item.name, [number], create=True)[0]
|
||||
batch = SerialBatchIdentity("Batch").resolve(item.name, ["PRINT-BATCH"], create=True)[0]
|
||||
doc = make_purchase_receipt(
|
||||
item_code=item.name,
|
||||
qty=1,
|
||||
rate=100,
|
||||
use_serial_batch_fields=1,
|
||||
serial_no=serial,
|
||||
batch_no=batch,
|
||||
do_not_submit=True,
|
||||
)
|
||||
table = {
|
||||
"fieldtype": "Table",
|
||||
"fieldname": "items",
|
||||
"table_columns": [
|
||||
{"fieldname": "serial_no", "fieldtype": "Small Text", "label": "Serial No", "width": 50},
|
||||
{
|
||||
"fieldname": "batch_no",
|
||||
"fieldtype": "Link",
|
||||
"options": "Batch",
|
||||
"label": "Batch No",
|
||||
"width": 50,
|
||||
},
|
||||
],
|
||||
}
|
||||
print_format = frappe.new_doc("Print Format")
|
||||
print_format.update(
|
||||
{
|
||||
"doc_type": "Purchase Receipt",
|
||||
"print_format_builder_beta": 1,
|
||||
"pdf_generator": "chrome",
|
||||
"format_data": frappe.as_json({"sections": [{"columns": [{"fields": [table]}]}]}),
|
||||
}
|
||||
)
|
||||
set_link_titles(doc)
|
||||
return doc, print_format
|
||||
|
||||
def test_builder_preview_displays_physical_numbers(self):
|
||||
doc, print_format = self.make_print()
|
||||
serial, batch = doc.items[0].serial_no, doc.items[0].batch_no
|
||||
for _ in range(2):
|
||||
html = PrintFormatGenerator(print_format, doc).get_html_preview()
|
||||
self.assertIn("PRINT-SERIAL", html)
|
||||
self.assertIn("PRINT-BATCH", html)
|
||||
self.assertNotIn(serial, html)
|
||||
self.assertNotIn(batch, html)
|
||||
self.assertEqual(doc.items[0].serial_no, serial)
|
||||
self.assertEqual(doc.items[0].batch_no, batch)
|
||||
self.assertEqual(doc.as_dict()["items"][0]["serial_no"], serial)
|
||||
|
||||
def test_builder_pdf_receives_physical_numbers(self):
|
||||
doc, print_format = self.make_print()
|
||||
serial = doc.items[0].serial_no
|
||||
with patch("frappe.utils.pdf.get_chrome_pdf", return_value=b"pdf") as render_pdf:
|
||||
self.assertEqual(PrintFormatGenerator(print_format, doc).render_pdf(), b"pdf")
|
||||
html = render_pdf.call_args.kwargs["html"]
|
||||
self.assertIn("PRINT-SERIAL", html)
|
||||
self.assertNotIn(serial, html)
|
||||
self.assertEqual(doc.items[0].serial_no, serial)
|
||||
|
||||
def test_builder_canvas_displays_physical_numbers(self):
|
||||
from frappe.utils.print_format_generator import get_formatted_field_values
|
||||
|
||||
doc, _ = self.make_print()
|
||||
values = get_formatted_field_values(doc.doctype, doc.name)
|
||||
self.assertEqual(values["child"]["items"][0]["serial_no"], "PRINT-SERIAL")
|
||||
self.assertEqual(values["child"]["items"][0]["batch_no"], "PRINT-BATCH")
|
||||
self.assertEqual(frappe.get_doc(doc.doctype, doc.name).items[0].serial_no, doc.items[0].serial_no)
|
||||
|
||||
def test_print_escapes_physical_serial_numbers(self):
|
||||
number = "PRINT-<img src=x onerror=alert(1)>"
|
||||
doc, print_format = self.make_print(number)
|
||||
serial = doc.items[0].serial_no
|
||||
html = PrintFormatGenerator(print_format, doc).get_html_preview()
|
||||
self.assertIn(escape_html(number), html)
|
||||
self.assertNotIn(number, html)
|
||||
self.assertEqual(doc.items[0].serial_no, serial)
|
||||
|
||||
def test_print_preserves_pending_physical_input(self):
|
||||
doc, print_format = self.make_print()
|
||||
row = doc.items[0]
|
||||
serial = row.serial_no
|
||||
row.__dict__["__serial_batch_input"] = ["serial_no"]
|
||||
html = PrintFormatGenerator(print_format, doc).get_html_preview()
|
||||
self.assertIn(serial, html)
|
||||
self.assertNotIn("PRINT-SERIAL", html)
|
||||
self.assertEqual(row.serial_no, serial)
|
||||
self.assertEqual(row.get("__serial_batch_input"), ["serial_no"])
|
||||
|
||||
def test_formatting_fetches_serial_labels_for_all_rows_together(self):
|
||||
doc, _ = self.make_print()
|
||||
serial = doc.items[0].serial_no
|
||||
other_serial = SerialBatchIdentity("Serial No").resolve(
|
||||
doc.items[0].item_code, ["OTHER-PRINT-SERIAL"], create=True
|
||||
)[0]
|
||||
doc.append("items", {"item_code": doc.items[0].item_code, "serial_no": other_serial})
|
||||
with patch.object(
|
||||
SerialBatchIdentity, "labels", autospec=True, side_effect=SerialBatchIdentity.labels
|
||||
) as labels:
|
||||
self.assertEqual(doc.items[0].get_formatted("serial_no"), "PRINT-SERIAL")
|
||||
self.assertEqual(doc.items[1].get_formatted("serial_no"), "OTHER-PRINT-SERIAL")
|
||||
self.assertEqual(doc.items[0].get_formatted("serial_no"), "PRINT-SERIAL")
|
||||
self.assertEqual(labels.call_count, 1)
|
||||
self.assertEqual(set(labels.call_args.args[1]), {serial, other_serial})
|
||||
self.assertNotIn("__serial_number_labels", doc.as_dict()["items"][0])
|
||||
|
||||
def test_custom_print_can_still_look_up_serial_by_id(self):
|
||||
doc, _ = self.make_print()
|
||||
template = (
|
||||
"{{ frappe.db.get_value('Serial No', doc.items[0].serial_no, 'serial_no') }} / "
|
||||
"{{ doc.items[0].get_formatted('serial_no') }}"
|
||||
)
|
||||
self.assertEqual(frappe.render_template(template, {"doc": doc}), "PRINT-SERIAL / PRINT-SERIAL")
|
||||
|
||||
def test_merged_builder_columns_preserve_special_characters(self):
|
||||
for number in ("SERIAL-&<001>", "SERIAL-\"quote\"-'single'", "SERIAL-&-<"):
|
||||
for primary in (True, False):
|
||||
with self.subTest(number=number, primary=primary):
|
||||
doc, print_format = self.make_print(number)
|
||||
serial = doc.items[0].serial_no
|
||||
layout = frappe.parse_json(print_format.format_data)
|
||||
table = layout["sections"][0]["columns"][0]["fields"][0]
|
||||
serial_column, batch_column = table["table_columns"]
|
||||
column, merged = (
|
||||
(serial_column, batch_column) if primary else (batch_column, serial_column)
|
||||
)
|
||||
column["merged_fields"] = [{**merged, "style": "secondary"}]
|
||||
table["table_columns"] = [column]
|
||||
print_format.format_data = frappe.as_json(layout)
|
||||
generator = PrintFormatGenerator(print_format, doc)
|
||||
preview = generator.get_html_preview()
|
||||
with patch("frappe.utils.pdf.get_chrome_pdf", return_value=b"pdf") as render_pdf:
|
||||
generator.render_pdf()
|
||||
for html in (preview, render_pdf.call_args.kwargs["html"]):
|
||||
cells = BeautifulSoup(html, "html.parser").select(".cell-line")
|
||||
self.assertIn(number, [cell.get_text() for cell in cells])
|
||||
self.assertTrue(all(cell.find() is None for cell in cells))
|
||||
self.assertNotIn(serial, html)
|
||||
self.assertEqual(doc.items[0].serial_no, serial)
|
||||
@@ -78,7 +78,7 @@ class TestStockUtilities(ERPNextTestSuite, StockTestMixin):
|
||||
batch_item = self.make_item(properties={"has_batch_no": 1, "create_new_batch": 1})
|
||||
batch = frappe.get_doc(doctype="Batch", item=batch_item.name).insert()
|
||||
|
||||
batch_scan = scan_barcode(batch.name)
|
||||
batch_scan = scan_barcode(batch.batch_id)
|
||||
self.assertEqual(batch_scan["item_code"], batch_item.name)
|
||||
self.assertEqual(batch_scan["batch_no"], batch.name)
|
||||
self.assertEqual(batch_scan["has_batch_no"], 1)
|
||||
@@ -92,7 +92,7 @@ class TestStockUtilities(ERPNextTestSuite, StockTestMixin):
|
||||
company="_Test Company",
|
||||
).insert()
|
||||
|
||||
serial_scan = scan_barcode(serial.name)
|
||||
serial_scan = scan_barcode(serial.serial_no)
|
||||
self.assertEqual(serial_scan["item_code"], serial_item.name)
|
||||
self.assertEqual(serial_scan["serial_no"], serial.name)
|
||||
self.assertEqual(serial_scan["has_batch_no"], 0)
|
||||
@@ -182,7 +182,7 @@ class TestStockUtilities(ERPNextTestSuite, StockTestMixin):
|
||||
serial_nos = []
|
||||
for rate in (10, 30):
|
||||
sn = "_TAVG" + random_string(8)
|
||||
frappe.get_doc(
|
||||
serial = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"serial_no": sn,
|
||||
@@ -191,6 +191,6 @@ class TestStockUtilities(ERPNextTestSuite, StockTestMixin):
|
||||
"purchase_rate": rate,
|
||||
}
|
||||
).insert()
|
||||
serial_nos.append(sn)
|
||||
serial_nos.append(serial.name)
|
||||
|
||||
self.assertEqual(flt(get_avg_purchase_rate("\n".join(serial_nos))), 20.0)
|
||||
|
||||
@@ -596,72 +596,67 @@ def check_pending_reposting(posting_date: str, company: str | None = None, throw
|
||||
return bool(reposting_pending)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def scan_barcode(search_value: str, ctx: dict | str | None = None) -> BarcodeScanResult:
|
||||
def set_cache(data: BarcodeScanResult):
|
||||
frappe.cache().set_value(f"erpnext:barcode_scan:{search_value}", data, expires_in_sec=120)
|
||||
_update_item_info(data, ctx)
|
||||
@frappe.whitelist(methods=["GET", "POST"])
|
||||
def scan_barcode(search_value: str, ctx: dict | str | None = None, allow_multiple: bool = False) -> dict:
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
def get_cache() -> BarcodeScanResult | None:
|
||||
data = frappe.cache().get_value(f"erpnext:barcode_scan:{search_value}")
|
||||
if not data:
|
||||
return
|
||||
|
||||
_update_item_info(data, ctx)
|
||||
return data
|
||||
|
||||
if ctx is None:
|
||||
ctx = frappe._dict()
|
||||
|
||||
if scan_data := get_cache():
|
||||
return scan_data
|
||||
|
||||
# search barcode no
|
||||
barcode_data = frappe.db.get_value(
|
||||
"Item Barcode",
|
||||
{"barcode": search_value},
|
||||
["barcode", "parent as item_code", "uom"],
|
||||
as_dict=True,
|
||||
ctx = frappe._dict(frappe.parse_json(ctx) or {})
|
||||
if ctx.item_code and not isinstance(ctx.item_code, str):
|
||||
frappe.throw(_("Item Code must be a string"))
|
||||
search_value = search_value.strip()
|
||||
candidates = []
|
||||
barcode_filters = {"barcode": search_value}
|
||||
if ctx.item_code:
|
||||
barcode_filters["parent"] = ctx.item_code
|
||||
barcode = frappe.db.get_value(
|
||||
"Item Barcode", barcode_filters, ["barcode", "parent as item_code", "uom"], as_dict=True
|
||||
)
|
||||
if barcode_data:
|
||||
set_cache(barcode_data)
|
||||
return barcode_data
|
||||
if barcode:
|
||||
candidates.append(barcode)
|
||||
|
||||
# search serial no
|
||||
serial_no_data = frappe.db.get_value(
|
||||
"Serial No",
|
||||
search_value,
|
||||
["name as serial_no", "item_code", "batch_no"],
|
||||
as_dict=True,
|
||||
)
|
||||
if serial_no_data:
|
||||
set_cache(serial_no_data)
|
||||
return serial_no_data
|
||||
|
||||
# search batch no
|
||||
batch_no_data = frappe.db.get_value(
|
||||
"Batch",
|
||||
search_value,
|
||||
["name as batch_no", "item as item_code"],
|
||||
as_dict=True,
|
||||
)
|
||||
if batch_no_data:
|
||||
if frappe.get_cached_value("Item", batch_no_data.item_code, "has_serial_no"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Batch No {0} is linked with Item {1} which has serial no. Please scan serial no instead."
|
||||
).format(search_value, batch_no_data.item_code)
|
||||
for doctype, fields in (
|
||||
(
|
||||
"Serial No",
|
||||
["name as serial_no", "serial_no as serial_number", "item_code", "batch_no"],
|
||||
),
|
||||
("Batch", ["name as batch_no", "batch_id as batch_number", "item as item_code"]),
|
||||
):
|
||||
if not frappe.has_permission(doctype, "read"):
|
||||
continue
|
||||
candidates.extend(
|
||||
SerialBatchIdentity(doctype)
|
||||
.get_query(
|
||||
[search_value],
|
||||
ctx.item_code,
|
||||
fields=fields,
|
||||
filters={"disabled": 0} if doctype == "Batch" else None,
|
||||
ignore_permissions=False,
|
||||
)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
set_cache(batch_no_data)
|
||||
return batch_no_data
|
||||
batch_labels = SerialBatchIdentity("Batch").labels(
|
||||
[candidate.batch_no for candidate in candidates if candidate.get("batch_no")]
|
||||
)
|
||||
for candidate in candidates:
|
||||
if candidate.get("batch_no"):
|
||||
candidate.batch_number = batch_labels.get(candidate.batch_no, candidate.batch_no)
|
||||
_update_item_info(candidate, ctx)
|
||||
|
||||
if len(candidates) > 1:
|
||||
if allow_multiple:
|
||||
return {"candidates": candidates}
|
||||
frappe.throw(_("This number matches multiple records. Select the item and serial or batch record."))
|
||||
|
||||
if candidates:
|
||||
candidate = candidates[0]
|
||||
if candidate.get("batch_no") and not candidate.get("serial_no") and candidate.get("has_serial_no"):
|
||||
frappe.throw(_("Please scan a serial number for Item {0}").format(candidate.item_code))
|
||||
return candidate
|
||||
|
||||
warehouse = frappe.get_cached_value("Warehouse", search_value, ("name", "disabled"), as_dict=True)
|
||||
if warehouse and not warehouse.disabled:
|
||||
warehouse_data = {"warehouse": warehouse.name}
|
||||
set_cache(warehouse_data)
|
||||
return warehouse_data
|
||||
|
||||
return {"warehouse": warehouse.name}
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
|
||||
make_subcontracting_receipt,
|
||||
)
|
||||
@@ -1753,14 +1754,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
batch_no = "BATCH-BNGS-0001"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": fg_item,
|
||||
}
|
||||
).insert()
|
||||
batch_no = SerialBatchIdentity("Batch").resolve(fg_item, [batch_no], create=True)[0]
|
||||
|
||||
scr = make_subcontracting_receipt(sco.name)
|
||||
self.assertFalse(scr.items[0].serial_and_batch_bundle)
|
||||
@@ -1830,14 +1824,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
batch_no = "BATCH-REJ-BNGS-0001"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": fg_item,
|
||||
}
|
||||
).insert()
|
||||
batch_no = SerialBatchIdentity("Batch").resolve(fg_item, [batch_no], create=True)[0]
|
||||
|
||||
rej_warehouse = create_warehouse("_Test Subcontract Warehouse For Rejected Qty")
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class SubcontractingReceiptItem(Document):
|
||||
class SubcontractingReceiptItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class SubcontractingReceiptSuppliedItem(Document):
|
||||
class SubcontractingReceiptSuppliedItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -18,6 +18,38 @@ class UOMMustBeIntegerError(frappe.ValidationError):
|
||||
|
||||
|
||||
class TransactionBase(StatusUpdater):
|
||||
def as_dict(
|
||||
self,
|
||||
no_nulls=False,
|
||||
no_default_fields=False,
|
||||
convert_dates_to_str=False,
|
||||
no_child_table_fields=False,
|
||||
no_private_properties=False,
|
||||
**kwargs,
|
||||
):
|
||||
doc = super().as_dict(
|
||||
no_nulls=no_nulls,
|
||||
no_default_fields=no_default_fields,
|
||||
convert_dates_to_str=convert_dates_to_str,
|
||||
no_child_table_fields=no_child_table_fields,
|
||||
no_private_properties=no_private_properties,
|
||||
**kwargs,
|
||||
)
|
||||
if not no_private_properties:
|
||||
for df in self.meta.get_table_fields():
|
||||
for row, values in zip(
|
||||
self.get(df.fieldname) or [], doc.get(df.fieldname) or [], strict=True
|
||||
):
|
||||
if row.meta.has_field("serial_and_batch_bundle") and row.get("__serial_batch_input"):
|
||||
values["__serial_batch_input"] = row.get("__serial_batch_input").copy()
|
||||
return doc
|
||||
|
||||
def _validate_links(self):
|
||||
from erpnext.stock.serial_batch_input import resolve_transaction_numbers
|
||||
|
||||
resolve_transaction_numbers(self)
|
||||
return super()._validate_links()
|
||||
|
||||
def on_change(self):
|
||||
# `on_change` also fires for `db_set()`, so only run during an actual insert/save.
|
||||
is_real_save = self.flags.in_insert or (self.doctype, self.name) in frappe.flags.currently_saving
|
||||
|
||||
Reference in New Issue
Block a user