mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
317 lines
14 KiB
Python
317 lines
14 KiB
Python
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):
|
|
from erpnext.stock.serial_batch_display import before_print
|
|
|
|
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
|
|
before_print(print_doc)
|
|
before_print(print_doc)
|
|
self.assertEqual(print_doc.items[0].serial_no, name)
|
|
self.assertIn("PRINT-123", print_doc.items[0].get_formatted("serial_no"))
|
|
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()
|
|
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.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")])
|