fix(stock): format serial numbers in builder prints

This commit is contained in:
Mihir Kandoi
2026-09-09 08:13:04 +05:30
parent 1426a098f1
commit dbfec6e9fb
20 changed files with 204 additions and 57 deletions

View File

@@ -2,10 +2,10 @@
# License: GNU General Public License v3. See license.txt # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.

View File

@@ -4,12 +4,12 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document
from erpnext.assets.doctype.asset.depreciation import get_disposal_account_and_cost_center 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # 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 # For license information, please see license.txt
# import frappe # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # 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 # For license information, please see license.txt
# import frappe # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # 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 # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.

View File

@@ -383,12 +383,11 @@ pre_submit_validation_doctypes = [
] ]
pdf_body_html = "erpnext.stock.serial_batch_display.pdf_body_html"
extend_doctype_class["Data Import"] = "erpnext.stock.serial_batch_import.SerialBatchDataImport" extend_doctype_class["Data Import"] = "erpnext.stock.serial_batch_import.SerialBatchDataImport"
doc_events = { doc_events = {
"*": { "*": {
"before_print": "erpnext.stock.serial_batch_display.set_serial_number_labels",
"validate": [ "validate": [
"erpnext.support.doctype.service_level_agreement.service_level_agreement.apply", "erpnext.support.doctype.service_level_agreement.service_level_agreement.apply",
"erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job", "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 # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # 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 # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # 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 # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # 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 # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.

View File

@@ -9,13 +9,13 @@ import json
import frappe import frappe
import frappe.defaults import frappe.defaults
from frappe import _ from frappe import _
from frappe.model.document import Document
from frappe.utils import flt from frappe.utils import flt
from erpnext.stock.get_item_details import get_item_details, get_price_list_rate 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.

View File

@@ -3,10 +3,10 @@
# import frappe # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # 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 # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.

View File

@@ -3,7 +3,6 @@
import frappe import frappe
from frappe import _, bold from frappe import _, bold
from frappe.model.document import Document
from frappe.utils import ( from frappe.utils import (
flt, flt,
get_link_to_form, get_link_to_form,
@@ -13,10 +12,11 @@ from frappe.utils import (
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
OpeningEntryAccountError, OpeningEntryAccountError,
) )
from erpnext.stock.serial_batch_display import SerialBatchReference
from erpnext.stock.stock_ledger import get_previous_sle from erpnext.stock.stock_ledger import get_previous_sle
class StockEntryDetail(Document): class StockEntryDetail(SerialBatchReference):
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.

View File

@@ -8,7 +8,6 @@ from datetime import date
import frappe import frappe
from frappe import _ from frappe import _
from frappe.core.doctype.role.role import get_users 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.query_builder.functions import Concat_ws, Max, Sum
from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate 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.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.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_bundle import SerialBatchBundle, get_serial_nos
from erpnext.stock.serial_batch_display import SerialBatchReference
class StockFreezeError(frappe.ValidationError): class StockFreezeError(frappe.ValidationError):
@@ -38,7 +38,7 @@ class SerialNoInventoryDimensionError(frappe.ValidationError):
exclude_from_linked_with = True exclude_from_linked_with = True
class StockLedgerEntry(Document): class StockLedgerEntry(SerialBatchReference):
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # 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 # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.

View File

@@ -1,10 +1,11 @@
"""Display physical numbers while retaining document IDs for stock references.""" """Display physical numbers while retaining document IDs for stock references."""
from copy import deepcopy from copy import copy
from functools import wraps from functools import wraps
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document
from frappe.utils import escape_html from frappe.utils import escape_html
from erpnext.stock.serial_batch_identity import SerialBatchIdentity from erpnext.stock.serial_batch_identity import SerialBatchIdentity
@@ -60,28 +61,46 @@ def report_number_columns(columns, rows):
return columns, rows return columns, rows
def pdf_body_html(template, args, **kwargs): class SerialBatchReference(Document):
from frappe.utils.pdf import pdf_body_html as render_body 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)
print_doc = deepcopy(args["doc"]) names = self.get(fieldname).split("\n")
set_serial_number_labels(print_doc) labels = {}
return render_body(template, {**args, "doc": print_doc}, **kwargs) 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): def set_serial_number_labels(doc, method=None, print_settings=None):
rows = [doc, *doc.get_all_children()] rows = [row for row in [doc, *doc.get_all_children()] if isinstance(row, SerialBatchReference)]
fields = ("serial_no", "rejected_serial_no", "current_serial_no") names = {
serial_rows = [ name
row
for row in rows for row in rows
if row.doctype != "Serial No" and (row.get("item_code") or row.get("rm_item_code")) 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")
] ]
values = []
for row in serial_rows:
for field in fields:
meta = row.meta.get_field(field)
if meta and meta.fieldtype in ("Small Text", "Text", "Long Text") and row.get(field):
values.append((row, field, row.get(field).split("\n")))
labels = SerialBatchIdentity("Serial No").labels([name for _, _, names in values for name in names])
for row, field, names in values:
row.set(field, "\n".join(escape_html(labels.get(name, name)) for name in names))

View File

@@ -0,0 +1,129 @@
from unittest.mock import patch
import frappe
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")

View File

@@ -2,10 +2,10 @@
# For license information, please see license.txt # For license information, please see license.txt
# import frappe # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # 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 # For license information, please see license.txt
# import frappe # 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 # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.