diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py index 38de9a22686..fd319ce0406 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py @@ -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. diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py index c43bd4df7bd..abc58997e64 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py @@ -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. diff --git a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py index c56f87b67fa..abfee0543a2 100644 --- a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py +++ b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py @@ -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. diff --git a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.py b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.py index 4d41397a0b1..97ab2204140 100644 --- a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.py +++ b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.py @@ -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. diff --git a/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.py b/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.py index 6ed097100f6..59ee6d75d97 100644 --- a/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.py +++ b/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.py @@ -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. diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 272bf67957e..b8582a3644b 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -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" 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", diff --git a/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.py b/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.py index 0bc3eeea142..f98107acbe8 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.py +++ b/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.py @@ -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. diff --git a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.py b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.py index 448b081b9b3..bf1f6b324e5 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.py +++ b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.py @@ -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. diff --git a/erpnext/selling/doctype/installation_note_item/installation_note_item.py b/erpnext/selling/doctype/installation_note_item/installation_note_item.py index 8566ab06a4c..a870556883c 100644 --- a/erpnext/selling/doctype/installation_note_item/installation_note_item.py +++ b/erpnext/selling/doctype/installation_note_item/installation_note_item.py @@ -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. diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py index e10e14c2896..534c9634151 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py @@ -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. diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index f9a17bba1a7..40f108268d0 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -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. diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.py b/erpnext/stock/doctype/pick_list_item/pick_list_item.py index 97e6525c97b..c57ee08aa61 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.py +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.py @@ -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. diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py index a8dca441cbb..156274f4045 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py @@ -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. diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py index 5d3cbbd630d..f9b740db426 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py @@ -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. diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index da8eab3042d..c65bec64b3c 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -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 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. diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py index aa0d8ee1b26..769ad7a8676 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py @@ -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. diff --git a/erpnext/stock/serial_batch_display.py b/erpnext/stock/serial_batch_display.py index 96cae3e8537..f7dffb7b990 100644 --- a/erpnext/stock/serial_batch_display.py +++ b/erpnext/stock/serial_batch_display.py @@ -1,10 +1,11 @@ """Display physical numbers while retaining document IDs for stock references.""" -from copy import deepcopy +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 @@ -60,28 +61,46 @@ def report_number_columns(columns, rows): return columns, rows -def pdf_body_html(template, args, **kwargs): - from frappe.utils.pdf import pdf_body_html as render_body +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) - print_doc = deepcopy(args["doc"]) - set_serial_number_labels(print_doc) - return render_body(template, {**args, "doc": print_doc}, **kwargs) + 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): - rows = [doc, *doc.get_all_children()] - fields = ("serial_no", "rejected_serial_no", "current_serial_no") - serial_rows = [ - row +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 - 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)) diff --git a/erpnext/stock/tests/test_serial_batch_print.py b/erpnext/stock/tests/test_serial_batch_print.py new file mode 100644 index 00000000000..93582fab713 --- /dev/null +++ b/erpnext/stock/tests/test_serial_batch_print.py @@ -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-" + 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") diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py index 46c710afb48..e11a4606313 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py @@ -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. diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.py b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.py index 1c6ae80969a..4f2aea0ae1c 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.py @@ -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.