Compare commits

...

20 Commits

Author SHA1 Message Date
Mihir Kandoi
653ef6eff2 fix: batch and serial nos set only once 2026-09-09 10:33:55 +05:30
Mihir Kandoi
acbefcd603 fix(stock): deduplicate scans after serial reselection 2026-09-09 10:01:46 +05:30
Mihir Kandoi
445a30ba60 fix(stock): show physical serial and batch numbers in errors 2026-09-09 09:56:45 +05:30
Mihir Kandoi
fca005c935 fix(stock): allow reselecting pending serial and batch entries 2026-09-09 09:45:19 +05:30
Mihir Kandoi
e51c01628d style(stock): declare the Data Import class extension with the others 2026-09-09 08:33:58 +05:30
Mihir Kandoi
766e51ae58 refactor(stock): drop the forked data import runner
The Data Import override copied the core background runner to swap one
line. Core now calls get_importer, so the subclass is used by the real
import and the copy can go. This also drops the enqueue_after_commit
that the copy had added on top of core.

Requires frappe#42648.
2026-09-09 08:33:23 +05:30
Mihir Kandoi
3acaa55db9 test(stock): verify merged print column escaping 2026-09-09 08:19:18 +05:30
Mihir Kandoi
dbfec6e9fb fix(stock): format serial numbers in builder prints 2026-09-09 08:13:04 +05:30
Mihir Kandoi
1426a098f1 refactor(stock): detect serial and batch input from field metadata 2026-09-09 07:42:46 +05:30
Mihir Kandoi
92b6d708d8 fix(stock): reuse transaction serial and batch fields 2026-09-09 07:11:49 +05:30
Mihir Kandoi
fa244a3615 perf(stock): batch serial and batch number resolution 2026-09-08 22:52:56 +05:30
Mihir Kandoi
cdb12ecf9d test(stock): close CSV fixture before importing 2026-09-08 22:29:28 +05:30
Mihir Kandoi
86489d6905 test(stock): verify FIFO normalization preserves references 2026-09-08 22:29:21 +05:30
Mihir Kandoi
d81fe03776 fix(stock): apply serial and batch identity review fixes 2026-09-08 22:20:12 +05:30
Mihir Kandoi
f80cac927d test: remove standalone serial and barcode tests 2026-09-08 12:54:32 +05:30
Mihir Kandoi
687c7d55ba chore: remove serial and batch documentation assets 2026-09-08 12:52:55 +05:30
Mihir Kandoi
6f2cf3bf91 fix(stock): preserve serial selection and use internal IDs in tests 2026-09-08 12:49:02 +05:30
Mihir Kandoi
48818c963a fix(stock): resolve number aliases and detect migration conflicts 2026-09-08 12:48:45 +05:30
Mihir Kandoi
c67a57d9bd fix(stock): enforce serial permissions and case-insensitive numbers 2026-09-08 12:20:58 +05:30
Mihir Kandoi
ca880f6be7 feat(stock)!: separate serial and batch numbers from document IDs 2026-09-08 12:00:06 +05:30
99 changed files with 3475 additions and 1036 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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}%")
)
)

View File

@@ -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:

View File

@@ -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():

View File

@@ -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",

View File

@@ -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.

View File

@@ -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.

View File

@@ -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()

View File

@@ -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))

View File

@@ -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"))

View File

@@ -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

View File

View 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

View File

@@ -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";

View File

@@ -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]);

View 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>");
};

View File

@@ -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>

View 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];
},
};

View File

@@ -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;

View File

@@ -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.

View File

@@ -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()

View File

@@ -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):

View File

@@ -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]);
});

View File

@@ -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);

View File

@@ -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": [],

View File

@@ -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()

View File

@@ -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

View File

@@ -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")

View File

@@ -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.

View File

@@ -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(

View File

@@ -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)

View File

@@ -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(

View File

@@ -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")

View File

@@ -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.

View File

@@ -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,

View File

@@ -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.

View File

@@ -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"

View File

@@ -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.

View File

@@ -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",

View File

@@ -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()

View File

@@ -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"}]),
)

View File

@@ -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"):

View File

@@ -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
}

View File

@@ -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"])

View File

@@ -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"))

View File

@@ -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,

View File

@@ -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.

View File

@@ -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):

View File

@@ -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

View File

@@ -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"):

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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 = {}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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.

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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 = [], []

View File

@@ -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 = {}

View File

@@ -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"),
)

View 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")
]

View 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]))

View 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()
)

View 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"
)

View 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})

View File

@@ -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 (

View 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")])

View 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}))

View 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")

View 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()

View 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)

View 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))

View 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-&amp;-&lt;"):
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)

View File

@@ -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)

View File

@@ -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 {}

View File

@@ -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")

View File

@@ -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.

View File

@@ -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.

View File

@@ -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