From 61b660da0d0ce3002216a415921ac51d1c1220e9 Mon Sep 17 00:00:00 2001 From: Lautaro Juarez <38353324+lauty95@users.noreply.github.com> Date: Wed, 4 Jun 2025 10:35:37 -0300 Subject: [PATCH 01/21] 254 renovate stock reconciliation form view workflows (#271) --- beam/beam/scan/__init__.py | 13 ++++++++++++- beam/public/js/scan/scan.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/beam/beam/scan/__init__.py b/beam/beam/scan/__init__.py index 20a37ba3..e1f73eb7 100644 --- a/beam/beam/scan/__init__.py +++ b/beam/beam/scan/__init__.py @@ -7,7 +7,7 @@ import frappe from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry -from erpnext.stock.get_item_details import get_item_details +from erpnext.stock.get_item_details import get_item_details, get_valuation_rate @frappe.whitelist() @@ -216,6 +216,17 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di "currency": frappe.defaults.get_user_default("Currency"), } ) + valuation_rate = get_valuation_rate(barcode_doc.doc.name, target.company, target.warehouse) + if valuation_rate.get("valuation_rate"): + target.valuation_rate = valuation_rate.valuation_rate + target.barcode = barcode_doc.barcode + elif barcode_doc.doc.doctype == "Warehouse" and context.frm == "Stock Reconciliation": + target = frappe._dict( + { + "doctype": context.frm, + "warehouse": barcode_doc.doc.name, + } + ) target.barcode = barcode_doc.barcode if not target: diff --git a/beam/public/js/scan/scan.js b/beam/public/js/scan/scan.js index c987ed43..f2e683f9 100644 --- a/beam/public/js/scan/scan.js +++ b/beam/public/js/scan/scan.js @@ -188,6 +188,36 @@ class ScanHandler { frappe.model.set_value(row.doctype, row.name, 's_warehouse', barcode_context.target) frappe.model.set_value(row.doctype, row.name, 't_warehouse', barcode_context.target) } + } else if ( + barcode_context.doctype == 'Stock Reconciliation Item' || + barcode_context.doctype == 'Stock Reconciliation' + ) { + cur_frm.set_value('set_warehouse', barcode_context.target) + cur_frm.set_value('purpose', 'Stock Reconciliation') + frappe.call({ + method: 'erpnext.stock.doctype.stock_reconciliation.stock_reconciliation.get_items', + args: { + warehouse: barcode_context.target, + posting_date: cur_frm.doc.posting_date, + posting_time: cur_frm.doc.posting_time, + company: cur_frm.doc.company, + }, + callback: function (r) { + if (r.exc || !r.message || !r.message.length) return + + cur_frm.clear_table('items') + + r.message.forEach(row => { + let item = cur_frm.add_child('items') + $.extend(item, row) + + item.qty = item.qty || 0 + item.valuation_rate = item.valuation_rate || 0 + item.use_serial_batch_fields = cint(frappe.user_defaults?.use_serial_batch_fields) + }) + cur_frm.refresh_field('items') + }, + }) } } add_or_increment(barcode_context) { From 8a68850cc76f499643ad99e904390263b0f83f26 Mon Sep 17 00:00:00 2001 From: Lautaro Juarez <38353324+lauty95@users.noreply.github.com> Date: Wed, 20 Aug 2025 11:17:46 -0300 Subject: [PATCH 02/21] feat: time_logs as mobile_v15 (#287) --- beam/beam/scan/__init__.py | 5 +++-- beam/tests/setup.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/beam/beam/scan/__init__.py b/beam/beam/scan/__init__.py index e1f73eb7..e0dfdb37 100644 --- a/beam/beam/scan/__init__.py +++ b/beam/beam/scan/__init__.py @@ -249,8 +249,9 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di actions = frm.get(barcode_doc.doc.doctype, {}).get(context.frm, []) for action in actions: action["context"] = target - if isinstance(action.get("target"), str) and "." in action.get("target"): - serialized_target = action.get("target").split(".") + target_value = action.get("target") + if isinstance(target_value, str) and "." in target_value: + serialized_target = target_value.split(".") action["target"] = target.get(serialized_target[1]) return actions diff --git a/beam/tests/setup.py b/beam/tests/setup.py index 547b0105..abe94e7e 100644 --- a/beam/tests/setup.py +++ b/beam/tests/setup.py @@ -533,22 +533,35 @@ def create_production_plan(settings, prod_plan_from_doc): pp.make_work_order() wos = frappe.get_all("Work Order", {"production_plan": pp.name}) + start_time = datetime.datetime(settings.day.year, settings.day.month, settings.day.day, 0, 0) for wo in wos: wo = frappe.get_doc("Work Order", wo) wo.wip_warehouse = "Kitchen - APC" + wo.actual_start_date = wo.planned_start_date = start_time + wo.required_items = sorted(wo.required_items, key=lambda x: x.get("item_code")) + for idx, w in enumerate(wo.required_items, start=1): + w.idx = idx wo.save() wo.submit() + frappe.db.set_value("Work Order", wo.name, "creation", start_time) job_cards = frappe.get_all("Job Card", {"work_order": wo.name}) for job_card in job_cards: job_card = frappe.get_doc("Job Card", job_card) + batch_size, total_operation_time = frappe.get_value( + "Operation", job_card.operation, ["batch_size", "total_operation_time"] + ) + time_in_mins = (total_operation_time / batch_size) * wo.qty job_card.append( "time_logs", { - "completed_qty": wo.qty, + "from_time": start_time, + "to_time": start_time + datetime.timedelta(minutes=time_in_mins), + "time_in_mins": time_in_mins, + "remaining_time_in_mins": time_in_mins, }, ) job_card.save() - job_card.submit() + start_time = job_card.time_logs[0].to_time + datetime.timedelta(minutes=2) def create_purchase_receipt_for_received_qty_test(settings): From 54f8dcec618baeaab047fa74458e50b627fab845 Mon Sep 17 00:00:00 2001 From: AgriTheory Date: Wed, 20 Aug 2025 14:18:38 +0000 Subject: [PATCH 03/21] 15.5.0 Automatically generated by python-semantic-release --- beam/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beam/__init__.py b/beam/__init__.py index 1f0539c1..304047bb 100644 --- a/beam/__init__.py +++ b/beam/__init__.py @@ -1,4 +1,4 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt -__version__ = "15.4.0" +__version__ = "15.5.0" diff --git a/pyproject.toml b/pyproject.toml index 1c7b305c..ccaa8ae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beam" -version = "15.4.0" +version = "15.5.0" authors = ["AgriTheory "] description = "Barcode Scanning for ERPNext" readme = "README.md" From 725e4bef1421cff03c506810410433e8390b477e Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Wed, 15 Oct 2025 10:49:32 -0400 Subject: [PATCH 04/21] Serial no (#290) Co-authored-by: fproldan --- .pre-commit-config.yaml | 2 +- beam/beam/barcodes.py | 39 +- .../doctype/beam_settings/beam_settings.json | 62 ++- .../microqr_serial_no/__init__.py | 2 + .../microqr_serial_no/microqr_serial_no.json | 33 ++ beam/beam/scan/__init__.py | 430 +++++++++++++++++- beam/hooks.py | 2 + beam/tests/fixtures.py | 13 + beam/tests/setup.py | 14 +- beam/tests/test_serial_number.py | 129 ++++++ 10 files changed, 692 insertions(+), 34 deletions(-) create mode 100644 beam/beam/print_format/microqr_serial_no/__init__.py create mode 100644 beam/beam/print_format/microqr_serial_no/microqr_serial_no.json create mode 100644 beam/tests/test_serial_number.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aa7cc233..e8682e4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: - tomli - repo: https://github.com/agritheory/test_utils - rev: v0.17.0 + rev: v1.1.0 hooks: - id: update_pre_commit_config - id: validate_copyright diff --git a/beam/beam/barcodes.py b/beam/beam/barcodes.py index 3404ef31..1d8f3477 100644 --- a/beam/beam/barcodes.py +++ b/beam/beam/barcodes.py @@ -6,6 +6,7 @@ from io import BytesIO import frappe +import pyqrcode from barcode import Code128 from barcode.writer import ImageWriter from erpnext import get_default_company @@ -53,10 +54,42 @@ def barcode128(barcode_text: str) -> str: ) font_size = settings.barcode_font_size or 0 temp = BytesIO() - instance = Code128(barcode_text, writer=ImageWriter()) - instance.write( - options={"module_width": 0.4, "module_height": 10, "font_size": font_size, "compress": True}, + + barcode_instance = Code128(barcode_text, writer=ImageWriter()) + options = {"module_width": 0.4, "module_height": 10, "font_size": font_size, "compress": True} + + barcode_instance.write(temp, options) + encoded = base64.b64encode(temp.getvalue()).decode("ascii") + return f'' + + +@frappe.whitelist() +@frappe.read_only() +def get_qr_code(qr_text: str) -> str: + if not qr_text: + return "" + + company = get_default_company() + settings = ( + create_beam_settings(company) + if not frappe.db.exists("BEAM Settings", {"company": company}) + else frappe.get_doc("BEAM Settings", {"company": company}) + ) + + qr_scale = getattr(settings, "qr_scale", 8) # Module size in pixels + qr_border = getattr(settings, "qr_border", 4) # Border size in modules + qr_error_correct = getattr(settings, "qr_error_correct", "M") # Error correction level + + qr = pyqrcode.create(qr_text, error=qr_error_correct) + temp = BytesIO() + qr.png( + temp, + scale=int(qr_scale), + module_color=(0, 0, 0, 255), + background=(255, 255, 255, 255), + quiet_zone=int(qr_border), ) + temp.seek(0) encoded = base64.b64encode(temp.getvalue()).decode("ascii") return f'' diff --git a/beam/beam/doctype/beam_settings/beam_settings.json b/beam/beam/doctype/beam_settings/beam_settings.json index e2f175ea..e6db82a9 100644 --- a/beam/beam/doctype/beam_settings/beam_settings.json +++ b/beam/beam/doctype/beam_settings/beam_settings.json @@ -1,22 +1,18 @@ { "actions": [], - "allow_rename": 1, - "autoname": "field:company", "creation": "2024-03-18 17:06:58.552999", "doctype": "DocType", "engine": "InnoDB", - "field_order": ["company", "enable_handling_units", "barcode_font_size"], + "field_order": [ + "enable_handling_units", + "scan_serial_no", + "barcode_font_size", + "column_break_vhpb", + "qr_scale", + "qr_border", + "qr_error_correct" + ], "fields": [ - { - "fieldname": "company", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Company", - "options": "Company", - "reqd": 1, - "set_only_once": 1, - "unique": 1 - }, { "default": "1", "fieldname": "enable_handling_units", @@ -29,11 +25,42 @@ "fieldname": "barcode_font_size", "fieldtype": "Int", "label": "Barcode Font Size" + }, + { + "default": "8", + "fieldname": "qr_scale", + "fieldtype": "Int", + "label": "QR Scale", + "non_negative": 1 + }, + { + "default": "4", + "fieldname": "qr_border", + "fieldtype": "Int", + "label": "QR Border", + "non_negative": 1 + }, + { + "default": "M", + "fieldname": "qr_error_correct", + "fieldtype": "Select", + "label": "QR Error Correct", + "options": "L\nM\nQ\nH" + }, + { + "fieldname": "column_break_vhpb", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "scan_serial_no", + "fieldtype": "Check", + "label": "Enable Scanning of Serial Numbers" } ], - "index_web_pages_for_search": 1, + "issingle": 1, "links": [], - "modified": "2024-05-29 01:43:57.177980", + "modified": "2025-08-29 10:31:24.924249", "modified_by": "Administrator", "module": "BEAM", "name": "BEAM Settings", @@ -44,10 +71,8 @@ "create": 1, "delete": 1, "email": 1, - "export": 1, "print": 1, "read": 1, - "report": 1, "role": "System Manager", "share": 1, "write": 1 @@ -56,15 +81,14 @@ "create": 1, "delete": 1, "email": 1, - "export": 1, "print": 1, "read": 1, - "report": 1, "role": "Stock Manager", "share": 1, "write": 1 } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/beam/beam/print_format/microqr_serial_no/__init__.py b/beam/beam/print_format/microqr_serial_no/__init__.py new file mode 100644 index 00000000..b1279b72 --- /dev/null +++ b/beam/beam/print_format/microqr_serial_no/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt diff --git a/beam/beam/print_format/microqr_serial_no/microqr_serial_no.json b/beam/beam/print_format/microqr_serial_no/microqr_serial_no.json new file mode 100644 index 00000000..ea87ff73 --- /dev/null +++ b/beam/beam/print_format/microqr_serial_no/microqr_serial_no.json @@ -0,0 +1,33 @@ +{ + "absolute_value": 0, + "align_labels_right": 0, + "creation": "2025-08-11 13:33:35.315005", + "css": "", + "custom_format": 1, + "default_print_language": "en-US", + "disabled": 0, + "doc_type": "Serial No", + "docstatus": 0, + "doctype": "Print Format", + "font_size": 14, + "html": "{% set sn = get_serial_no(doc.name) %}\n\n\n
\n
0mm
\n
0mm
\n
0mm
\n
0mm
\n\n
\n
\n \n
\n
\n {{get_qr_code(sn.serial_no)}}\n
\n
\n
{{ sn.item_code or doc.item_code }}
\n
{{ sn.serial_no }}
\n
{{ frappe.utils.format_datetime(sn.posting_datetime) }}
\n
{{ sn.company }}
\n
\n\n
\n
\n\n", + "idx": 0, + "line_breaks": 0, + "margin_bottom": 15.0, + "margin_left": 15.0, + "margin_right": 15.0, + "margin_top": 15.0, + "modified": "2025-08-11 15:14:09.683495", + "modified_by": "Administrator", + "module": "BEAM", + "name": "MicroQR Serial No", + "owner": "Administrator", + "page_number": "Hide", + "pdf_generator": "wkhtmltopdf", + "print_format_builder": 0, + "print_format_builder_beta": 0, + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 0, + "standard": "Yes" +} diff --git a/beam/beam/scan/__init__.py b/beam/beam/scan/__init__.py index e0dfdb37..c3231055 100644 --- a/beam/beam/scan/__init__.py +++ b/beam/beam/scan/__init__.py @@ -8,6 +8,9 @@ import frappe from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry from erpnext.stock.get_item_details import get_item_details, get_valuation_rate +from frappe.query_builder import Case, DocType +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Coalesce @frappe.whitelist() @@ -31,17 +34,51 @@ def scan( def get_barcode_context(barcode: str) -> frappe._dict | None: + settings = frappe.get_cached_doc("BEAM Settings", "BEAM Settings") item_barcode = frappe.db.get_value( "Item Barcode", {"barcode": barcode}, ["parent", "parenttype"], as_dict=True ) - if not item_barcode: - return None # mypy asked for this - return frappe._dict( - { - "doc": frappe.get_doc(item_barcode.parenttype, item_barcode.parent), - "barcode": barcode, - } - ) + if item_barcode: + return frappe._dict( + { + "doc": frappe.get_doc(item_barcode.parenttype, item_barcode.parent), + "barcode": barcode, + } + ) + elif not item_barcode and settings.scan_serial_no: + serial_no_table = frappe.qb.DocType("Serial No") + bundle_entry_table = frappe.qb.DocType("Serial and Batch Entry") + bundle_table = frappe.qb.DocType("Serial and Batch Bundle") + serial_lookup = ( + ( + frappe.qb.from_(serial_no_table) + .select( + ConstantColumn("Serial No").as_("doctype"), + serial_no_table.name, + ) + .where(serial_no_table.name == barcode) + ) + .union( + frappe.qb.from_(bundle_entry_table) + .join(bundle_table) + .on(bundle_entry_table.parent == bundle_table.name) + .select( + ConstantColumn("Serial and Batch Bundle").as_("doctype"), + bundle_entry_table.parent, + ) + .where(bundle_entry_table.serial_no == barcode) + ) + .limit(1) + .run(as_dict=True) + ) + if serial_lookup: + return frappe._dict( + { + "doc": frappe.get_doc(serial_lookup[0].doctype, serial_lookup[0].name), + "barcode": barcode, + } + ) + return None def get_handling_unit(handling_unit: str, parent_doctype: str | None = None) -> frappe._dict: @@ -51,6 +88,7 @@ def get_handling_unit(handling_unit: str, parent_doctype: str | None = None) -> fields=[ "item_code", "SUM(actual_qty) AS stock_qty", + "company", "handling_unit", "voucher_no", "posting_date", @@ -138,6 +176,12 @@ def get_list_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di else: target = get_handling_unit(barcode_doc.doc.name) target = target.get("voucher_no") if target else None + elif barcode_doc.doc.doctype == "Serial No": + if context.get("listview") in ["Item", "Putaway Rule"]: + target = barcode_doc.doc.item_code + else: + target = get_serial_no(barcode_doc.doc.name, context.get("listview")) + target = target.get("voucher_no") if target else None if not target: return [] @@ -158,7 +202,6 @@ def get_list_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di for action in actions: action["context"] = target action["target"] = target - return actions @@ -228,6 +271,39 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di } ) target.barcode = barcode_doc.barcode + elif barcode_doc.doc.doctype == "Serial No": + serial_no_details = get_serial_no(barcode_doc.doc.name, context.frm) + if context.frm == "Stock Entry": + target = get_stock_entry_item_details(context.doc, serial_no_details.item_code) + target.warehouse = serial_no_details.warehouse + elif context.frm in ("Putaway Rule", "Warranty Claim", "Item Price", "Quality Inspection"): + target = frappe._dict( + { + "doctype": context.frm, + "item_code": serial_no_details.item_code, + } + ) + else: + target = get_item_details( + { + "doctype": context.frm, + "item_code": serial_no_details.item_code, + "company": frappe.defaults.get_user_default("Company"), + "currency": frappe.defaults.get_user_default("Currency"), + } + ) + target.update( + { + "handling_unit": serial_no_details.handling_unit, + "voucher_no": serial_no_details.voucher_no, + "stock_qty": serial_no_details.stock_qty, + "qty": serial_no_details.stock_qty / target.conversion_factor + if target.conversion_factor + else serial_no_details.stock_qty, + "posting_datetime": serial_no_details.posting_datetime, + "dn_detail": serial_no_details.dn_detail, + } + ) if not target: return [] @@ -257,6 +333,121 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di return actions +def get_serial_no(serial_no: str, parent_doctype: str | None = None) -> frappe._dict: + sle = DocType("Stock Ledger Entry") + snb = DocType("Serial and Batch Entry") + snb_bundle = DocType("Serial and Batch Bundle") + se_detail = DocType("Stock Entry Detail") + pr_item = DocType("Purchase Receipt Item") + pi_item = DocType("Purchase Invoice Item") + dn_item = DocType("Delivery Note Item") + + main_query = ( + frappe.qb.from_(sle) + .left_join(snb_bundle) + .on(sle.serial_and_batch_bundle == snb_bundle.name) + .left_join(snb) + .on(snb_bundle.name == snb.parent) + .left_join(se_detail) + .on((sle.voucher_type == "Stock Entry") & (sle.voucher_detail_no == se_detail.name)) + .left_join(pr_item) + .on((sle.voucher_type == "Purchase Receipt") & (sle.voucher_detail_no == pr_item.name)) + .left_join(pi_item) + .on((sle.voucher_type == "Purchase Invoice") & (sle.voucher_detail_no == pi_item.name)) + .left_join(dn_item) + .on((sle.voucher_type == "Delivery Note") & (sle.voucher_detail_no == dn_item.name)) + .select( + sle.item_code, + sle.actual_qty.as_("stock_qty"), + sle.company, + sle.voucher_no, + sle.posting_date, + sle.posting_time, + sle.stock_uom, + sle.voucher_type, + sle.voucher_detail_no, + sle.warehouse, + sle.serial_and_batch_bundle, + Coalesce(snb.serial_no, sle.serial_no).as_("serial_no"), + # Item details from whichever child table matches + Coalesce(se_detail.uom, pr_item.uom, pi_item.uom, dn_item.uom).as_("uom"), + Coalesce(se_detail.qty, pr_item.qty, pi_item.qty, dn_item.qty).as_("qty"), + Coalesce( + se_detail.conversion_factor, + pr_item.conversion_factor, + pi_item.conversion_factor, + dn_item.conversion_factor, + ).as_("conversion_factor"), + Coalesce(se_detail.idx, pr_item.idx, pi_item.idx, dn_item.idx).as_("idx"), + Coalesce(se_detail.item_name, pr_item.item_name, pi_item.item_name, dn_item.item_name).as_( + "item_name" + ), + Coalesce(se_detail.name, pr_item.name, pi_item.name, dn_item.name).as_("detail_name"), + # Special field for Purchase Receipt + Case() + .when(sle.voucher_type == "Purchase Receipt", pr_item.stock_qty) + .else_(None) + .as_("stock_qty_field"), + # For Packing Slip case - get delivery note item details + Case() + .when( + (dn_item.docstatus == 0) + & ((snb.serial_no == serial_no) | (dn_item.serial_no.like(f"%{serial_no}%"))), + dn_item.name, + ) + .else_(None) + .as_("dn_detail"), + ) + .where( + (sle.is_cancelled == 0) + & ( + (snb.serial_no == serial_no) + | (sle.serial_no.like(f"%{serial_no}%")) # Serial and Batch method # Direct field method + ) + ) + .groupby(sle.voucher_no, sle.voucher_detail_no) + .orderby(sle.posting_date, order=frappe.qb.desc) + .orderby(sle.posting_time, order=frappe.qb.desc) + .limit(1) + ) + + result = main_query.run(as_dict=True) + + if not result: + return + + sle_data = frappe._dict(result[0]) + + if sle_data.stock_qty_field is not None: + sle_data.stock_qty = sle_data.stock_qty_field + + if parent_doctype == "Packing Slip" and sle_data.dn_detail: + sle_data.dn_detail = sle_data.dn_detail + + sle_data.qty = 1.0 + + if sle_data.conversion_factor and sle_data.conversion_factor != 0: + sle_data.stock_qty = sle_data.qty / sle_data.conversion_factor + else: + sle_data.stock_qty = sle_data.qty + + sle_data.posting_datetime = ( + datetime.datetime( + sle_data.posting_date.year, sle_data.posting_date.month, sle_data.posting_date.day + ) + + sle_data.posting_time + ) + + sle_data.user = frappe.session.user + sle_data.pop("posting_date", None) + sle_data.pop("posting_time", None) + sle_data.pop("voucher_detail_no", None) + sle_data.pop("stock_qty_field", None) + sle_data.pop("detail_name", None) + + return sle_data + + listview = { "Handling Unit": { "Delivery Note": [ @@ -408,6 +599,53 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di {"action": "route", "doctype": "Warehouse", "field": "Warehouse", "target": "target"} ], }, + "Serial No": { + "Delivery Note": [ + {"action": "filter", "doctype": "Delivery Note", "field": "name", "target": "target"} + ], + "Item": [{"action": "route", "doctype": "Item", "field": "Item", "target": "target"}], + "Packing Slip": [ + {"action": "filter", "doctype": "Packing Slip", "field": "name", "target": "target"} + ], + "Purchase Invoice": [ + { + "action": "filter", + "doctype": "Purchase Invoice", + "field": "name", + "target": "target", + } + ], + "Purchase Receipt": [ + { + "action": "route", + "doctype": "Purchase Receipt", + "field": "Purchase Receipt", + "target": "target", + } + ], + "Putaway Rule": [ + {"action": "filter", "doctype": "Putaway Rule", "field": "item_code", "target": "target"}, + ], + "Quality Inspection": [ + { + "action": "filter", + "doctype": "Quality Inspection", + "field": "handling_unit", + "target": "target", + }, + ], + "Stock Entry": [ + {"action": "filter", "doctype": "Stock Entry", "field": "name", "target": "target"} + ], + "Stock Reconciliation": [ + { + "action": "filter", + "doctype": "Stock Reconciliation", + "field": "name", + "target": "target", + } + ], + }, } frm = { @@ -751,4 +989,178 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di }, ], }, + "Serial No": { + "Delivery Note": [ + { + "action": "add_or_associate", + "doctype": "Delivery Note Item", + "field": "handling_unit", + "target": "target.handling_unit", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Delivery Note Item", + "field": "rate", + "target": "target.rate", + "context": "target", + }, + ], + "Item Price": [ + { + "action": "set_item_code_and_handling_unit", + "doctype": "Item Price", + "field": "item_code", + "target": "target.item_code", + "context": "target", + }, + ], + "Packing Slip": [ + { + "action": "add_or_associate", + "doctype": "Packing Slip Item", + "field": "conversion_factor", + "target": "target.conversion_factor", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Packing Slip Item", + "field": "handling_unit", + "target": "target.handling_unit", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Packing Slip Item", + "field": "pulled_quantity", + "target": "target.qty", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Packing Slip Item", + "field": "rate", + "target": "target.rate", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Packing Slip Item", + "field": "stock_qty", + "target": "target.stock_qty", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Packing Slip Item", + "field": "warehouse", + "target": "target.warehouse", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Packing Slip Item", + "field": "dn_detail", + "target": "target.dn_detail", + "context": "target", + }, + ], + "Purchase Invoice": [ + { + "action": "add_or_associate", + "doctype": "Purchase Invoice Item", + "field": "handling_unit", + "target": "target.handling_unit", + "context": "target", + }, + ], + "Putaway Rule": [ + { + "action": "set_item_code_and_handling_unit", + "doctype": "Putaway Rule", + "field": "item_code", + "target": "target.item_code", + "context": "target", + }, + ], + "Quality Inspection": [ + { + "action": "set_item_code_and_handling_unit", + "doctype": "Quality Inspection", + "field": "item_code", + "target": "target.item_code", + "context": "target", + }, + { + "action": "set_item_code_and_handling_unit", + "doctype": "Quality Inspection", + "field": "handling_unit", + "target": "target.handling_unit", + "context": "target", + }, + ], + "Stock Entry": [ + { + "action": "add_or_associate", + "doctype": "Stock Entry Detail", + "field": "basic_rate", + "target": "target.valuation_rate", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Stock Entry Detail", + "field": "conversion_factor", + "target": "target.conversion_factor", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Stock Entry Detail", + "field": "handling_unit", + "target": "target.handling_unit", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Stock Entry Detail", + "field": "s_warehouse", + "target": "target.warehouse", + "context": "target", + }, + { + "action": "add_or_associate", + "doctype": "Stock Entry Detail", + "field": "transfer_qty", + "target": "target.stock_qty", + "context": "target", + }, + ], + "Stock Reconciliation": [ + { + "action": "add_or_associate", + "doctype": "Stock Reconciliation Item", + "field": "handling_unit", + "target": "target.handling_unit", + "context": "target", + }, + ], + "Warranty Claim": [ + { + "action": "set_item_code_and_handling_unit", + "doctype": "Warranty Claim", + "field": "item_code", + "target": "target.item_code", + "context": "target", + }, + { + "action": "set_item_code_and_handling_unit", + "doctype": "Warranty Claim", + "field": "handling_unit", + "target": "target.handling_unit", + "context": "target", + }, + ], + }, } diff --git a/beam/hooks.py b/beam/hooks.py index 53d939be..9a4140a7 100644 --- a/beam/hooks.py +++ b/beam/hooks.py @@ -60,6 +60,7 @@ "methods": [ "beam.beam.barcodes.add_to_label", "beam.beam.barcodes.barcode128", + "beam.beam.barcodes.get_qr_code", "beam.beam.barcodes.formatted_zpl_barcode", "beam.beam.barcodes.formatted_zpl_label", "beam.beam.barcodes.formatted_zpl_text", @@ -68,6 +69,7 @@ "beam.beam.barcodes.zebra_zpl_text", "beam.beam.printing.labelary_api", "beam.beam.scan.get_handling_unit", + "beam.beam.scan.get_serial_no", ], } diff --git a/beam/tests/fixtures.py b/beam/tests/fixtures.py index 12e924ea..520eea2a 100644 --- a/beam/tests/fixtures.py +++ b/beam/tests/fixtures.py @@ -368,6 +368,19 @@ "default_warehouse": "Kitchen - APC", "supplier": "Freedom Provisions", }, + { + "item_code": "Whipped Cream Canister", + "uom": "Nos", + "item_group": "Bakery Supplies", + "default_warehouse": "Storeroom - APC", + "description": "Pressurized whipped cream canister for serving pies; also sold retail.", + "item_price": 2.75, + "supplier": "Unity Bakery Supply", + "is_sales_item": 1, + "is_purchase_item": 1, + "has_serial_no": 1, + "serial_no_series": "WCC-.#####", + }, ] boms = [ diff --git a/beam/tests/setup.py b/beam/tests/setup.py index abe94e7e..cec6e367 100644 --- a/beam/tests/setup.py +++ b/beam/tests/setup.py @@ -259,8 +259,15 @@ def create_items(settings): "Purchase" if item.get("item_group") in ("Bakery Supplies", "Ingredients") else "Manufacture" ) i.valuation_method = "FIFO" - i.is_purchase_item = 1 if item.get("item_group") in ("Bakery Supplies", "Ingredients") else 0 - i.is_sales_item = 1 if item.get("item_group") == "Baked Goods" else 0 + i.is_purchase_item = ( + 1 + if item.get("item_group") in ("Bakery Supplies", "Ingredients") + or item.get("is_purchase_item", 0) + else 0 + ) + i.is_sales_item = ( + 1 if item.get("item_group") == "Baked Goods" or item.get("is_sales_item", 0) else 0 + ) i.append( "item_defaults", {"company": settings.company, "default_warehouse": item.get("default_warehouse")}, @@ -270,6 +277,9 @@ def create_items(settings): if i.item_code == "Parchment Paper": i.append("uoms", {"uom": "Box", "conversion_factor": 100}) i.purchase_uom = "Box" + + i.has_serial_no = item.get("has_serial_no", 0) or 0 + i.serial_no_series = item.get("serial_no_series", "") or "" i.save() if item.get("item_price"): ip = frappe.new_doc("Item Price") diff --git a/beam/tests/test_serial_number.py b/beam/tests/test_serial_number.py new file mode 100644 index 00000000..24db1089 --- /dev/null +++ b/beam/tests/test_serial_number.py @@ -0,0 +1,129 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +import pytest +from frappe.utils import today + + +def _make_serials(series="WCC-.#####", qty=1): + from frappe.model.naming import make_autoname + + return [make_autoname(series) for _ in range(qty)] + + +@pytest.mark.order(20) +def test_serial_number_scan(): + warehouse = "Storeroom - APC" + supplier = "Unity Bakery Supply" + item_code = "Whipped Cream Canister" + serials = _make_serials(qty=3) + pr = frappe.get_doc( + { + "doctype": "Purchase Receipt", + "supplier": supplier, + "posting_date": today(), + "items": [ + { + "item_code": item_code, + "qty": 1, + "received_qty": 1, + "rate": 10, + "warehouse": warehouse, + "serial_no": serials[0], + "use_serial_batch_fields": 1, + } + ], + } + ) + pr.save() + pr.submit() + + # Serial No scanning disabled + settings = frappe.get_doc("BEAM Settings", "BEAM Settings") + assert settings.scan_serial_no == 0 + scan = frappe.call( + "beam.beam.scan.scan", + **{"barcode": str(serials[0]), "context": {"listview": "Purchase Receipt"}} + ) + assert scan is None + + # Serial No scanning enabled + settings.scan_serial_no = 1 + settings.save() + + assert settings.scan_serial_no == 1 + scan = frappe.call( + "beam.beam.scan.scan", + **{"barcode": str(serials[0]), "context": {"listview": "Purchase Receipt"}} + ) + assert scan[0]["action"] == "route" + assert scan[0]["doctype"] == "Purchase Receipt" + assert scan[0]["field"] == "Purchase Receipt" + assert scan[0]["target"] == pr.name + + pi = frappe.get_doc( + { + "doctype": "Purchase Invoice", + "supplier": supplier, + "posting_date": today(), + "update_stock": 1, + "items": [ + { + "item_code": item_code, + "qty": 1, + "received_qty": 1, + "rate": 10, + "warehouse": warehouse, + "serial_no": serials[1], + "use_serial_batch_fields": 1, + } + ], + } + ) + pi.save() + pi.submit() + + settings = frappe.get_doc("BEAM Settings", "BEAM Settings") + settings.scan_serial_no = 1 + settings.save() + scan = frappe.call( + "beam.beam.scan.scan", + **{"barcode": str(serials[1]), "context": {"listview": "Purchase Invoice"}} + ) + assert scan[0]["action"] == "filter" + assert scan[0]["doctype"] == "Purchase Invoice" + assert scan[0]["field"] == "name" + assert scan[0]["target"] == pi.name + + dn = frappe.get_doc( + { + "doctype": "Delivery Note", + "customer": "Longwoods Sandwich Shop", + "posting_date": today(), + "items": [ + { + "item_code": item_code, + "qty": 1, + "received_qty": 1, + "rate": 10, + "warehouse": warehouse, + "serial_no": serials[1], + "use_serial_batch_fields": 1, + } + ], + } + ) + dn.save() + dn.submit() + + settings = frappe.get_doc("BEAM Settings", "BEAM Settings") + settings.scan_serial_no = 1 + settings.save() + scan = frappe.call( + "beam.beam.scan.scan", **{"barcode": str(serials[1]), "context": {"listview": "Delivery Note"}} + ) + assert scan[0]["action"] == "filter" + assert scan[0]["doctype"] == "Delivery Note" + assert scan[0]["field"] == "name" + assert scan[0]["target"] == dn.name From 479b42cc1c14a438be8c164f59de56d78e5183a0 Mon Sep 17 00:00:00 2001 From: IshwaryaM1030 Date: Tue, 6 Jan 2026 19:15:28 +0530 Subject: [PATCH 05/21] Add test for barcode generation in print format (#282) Co-authored-by: Ishwarya --- beam/beam/barcodes.py | 7 +++++- beam/tests/test_item_barcode_print_format.py | 23 ++++++++++++++++++++ setup.py | 15 ++++++++----- 3 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 beam/tests/test_item_barcode_print_format.py diff --git a/beam/beam/barcodes.py b/beam/beam/barcodes.py index 1d8f3477..e37ef30e 100644 --- a/beam/beam/barcodes.py +++ b/beam/beam/barcodes.py @@ -162,7 +162,12 @@ def add_to_label(label: Label, element: Printable): class ZPLLabelStringOutput(Label): def __init__( - self, width: int = 100, length: int = 100, dpi: int = 203, print_speed: int = 2, copies: int = 1 + self, + width: int = 100, + length: int = 100, + dpi: int = 203, + print_speed: int = 2, + copies: int = 1, ): super().__init__(width, length, dpi, print_speed, copies) diff --git a/beam/tests/test_item_barcode_print_format.py b/beam/tests/test_item_barcode_print_format.py new file mode 100644 index 00000000..46ce6a9e --- /dev/null +++ b/beam/tests/test_item_barcode_print_format.py @@ -0,0 +1,23 @@ +# Copyright (c) 2025, AgriTheory and contributors +# Test for barcode generation in Item Barcode print format + +import pytest + +from beam.beam.barcodes import barcode128 + + +@pytest.mark.parametrize("barcode_text", ["123456789012", "ITEM-00001", "987654321098"]) +def test_item_barcode_print_format(barcode_text): + # Generate barcode image in print format + img_html = barcode128(barcode_text) + assert img_html.startswith('') + # Optionally, check that the base64 string decodes to PNG + import base64 + import re + + match = re.search(r"data:image/png;base64,([A-Za-z0-9+/=]+)", img_html) + assert match, "No base64 PNG found in img tag" + png_bytes = base64.b64decode(match.group(1)) + assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n", "Not a PNG file" diff --git a/setup.py b/setup.py index 8e79b4d1..09463f34 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,12 @@ -# Copyright (c) 2025, AgriTheory and contributors +# Copyright (c) 2026, AgriTheory and contributors # For license information, please see license.txt -from setuptools import setup +from setuptools import find_packages, setup -# TODO: Remove this file when bench >=v5.11.0 is adopted / v15.0.0 is released -name = "beam" - -setup() +setup( + name="beam", + version="14.8.7", + packages=find_packages(), + include_package_data=True, + zip_safe=False, +) From a45b89fcaa59dbc78cb85804035cdfe57c1725f2 Mon Sep 17 00:00:00 2001 From: IshwaryaM1030 Date: Tue, 13 Jan 2026 20:58:33 +0530 Subject: [PATCH 06/21] Create BEAM Settings on creation of new Company (#306) Co-authored-by: Tyler Matteson --- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 3 +++ beam/beam/custom/bom_scrap_item.json | 8 ------- beam/beam/custom/item.json | 8 ------- beam/beam/custom/item_barcode.json | 6 ----- beam/beam/custom/stock_entry_detail.json | 9 -------- beam/beam/custom/warehouse.json | 14 ----------- .../doctype/beam_settings/beam_settings.json | 14 ++++++++--- beam/beam/overrides/company.py | 11 +++++++++ beam/customize.py | 4 +++- beam/hooks.py | 5 ++++ poetry.lock | 23 +++++++++++++++---- pyproject.toml | 10 +++++--- 13 files changed, 60 insertions(+), 57 deletions(-) create mode 100644 beam/beam/overrides/company.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8682e4c..14878e3f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: - tomli - repo: https://github.com/agritheory/test_utils - rev: v1.1.0 + rev: v1.7.1 hooks: - id: update_pre_commit_config - id: validate_copyright diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ddb3c67..cc9e6965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ + + # CHANGELOG diff --git a/beam/beam/custom/bom_scrap_item.json b/beam/beam/custom/bom_scrap_item.json index 95230daf..4037dd3e 100644 --- a/beam/beam/custom/bom_scrap_item.json +++ b/beam/beam/custom/bom_scrap_item.json @@ -6,9 +6,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "creation": "2023-08-22 15:23:16.272692", "default": null, - "docstatus": 0, "dt": "BOM Scrap Item", "fetch_if_empty": 0, "fieldname": "create_handling_unit", @@ -29,15 +27,11 @@ "is_virtual": 0, "label": "Create Handling Unit", "length": 0, - "modified": "2023-08-22 15:23:52.267428", - "modified_by": "Administrator", "module": "BEAM", "name": "BOM Scrap Item-create_handling_unit", "no_copy": 0, "non_negative": 0, - "owner": "Administrator", "permlevel": 0, - "precision": "", "print_hide": 0, "print_hide_if_no_value": 0, "read_only": 0, @@ -49,9 +43,7 @@ "unique": 0 } ], - "custom_perms": [], "doctype": "BOM Scrap Item", - "links": [], "property_setters": [], "sync_on_migrate": 1 } diff --git a/beam/beam/custom/item.json b/beam/beam/custom/item.json index ee256d60..03099d33 100644 --- a/beam/beam/custom/item.json +++ b/beam/beam/custom/item.json @@ -6,9 +6,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "creation": "2024-02-26 23:52:53.051024", "default": "1", - "docstatus": 0, "dt": "Item", "fetch_if_empty": 0, "fieldname": "enable_handling_unit", @@ -29,15 +27,11 @@ "is_virtual": 0, "label": "Enable Handling Unit", "length": 0, - "modified": "2024-02-26 23:52:53.051024", - "modified_by": "Administrator", "module": "BEAM", "name": "Item-enable_handling_unit", "no_copy": 0, "non_negative": 0, - "owner": "Administrator", "permlevel": 0, - "precision": "", "print_hide": 0, "print_hide_if_no_value": 0, "read_only": 0, @@ -49,9 +43,7 @@ "unique": 0 } ], - "custom_perms": [], "doctype": "Item", - "links": [], "property_setters": [], "sync_on_migrate": 1 } diff --git a/beam/beam/custom/item_barcode.json b/beam/beam/custom/item_barcode.json index 2e6bb577..55b70641 100644 --- a/beam/beam/custom/item_barcode.json +++ b/beam/beam/custom/item_barcode.json @@ -1,20 +1,14 @@ { "custom_fields": [], - "custom_perms": [], "doctype": "Item Barcode", "property_setters": [ { - "creation": "2022-06-16 09:40:22.875922", "doc_type": "Item Barcode", - "docstatus": 0, "doctype_or_field": "DocField", "field_name": "barcode_type", "idx": 0, - "modified": "2022-06-16 09:40:22.875922", - "modified_by": "Administrator", "module": "BEAM", "name": "Item Barcode-barcode_type-options", - "owner": "Administrator", "property": "options", "property_type": "Text", "value": "\nEAN\nUPC-A\nCode128" diff --git a/beam/beam/custom/stock_entry_detail.json b/beam/beam/custom/stock_entry_detail.json index 6fc6385b..1728d497 100644 --- a/beam/beam/custom/stock_entry_detail.json +++ b/beam/beam/custom/stock_entry_detail.json @@ -6,10 +6,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "creation": "2023-09-13 12:51:04.950175", "default": null, - "depends_on": "", - "docstatus": 0, "dt": "Stock Entry Detail", "fetch_if_empty": 0, "fieldname": "recombine_on_cancel", @@ -30,15 +27,11 @@ "is_virtual": 0, "label": "Recombine On Cancel", "length": 0, - "modified": "2023-09-13 12:51:04.950175", - "modified_by": "Administrator", "module": "BEAM", "name": "Stock Entry Detail-recombine_on_cancel", "no_copy": 1, "non_negative": 0, - "owner": "Administrator", "permlevel": 0, - "precision": "", "print_hide": 0, "print_hide_if_no_value": 0, "read_only": 1, @@ -50,9 +43,7 @@ "unique": 0 } ], - "custom_perms": [], "doctype": "Stock Entry Detail", - "links": [], "property_setters": [], "sync_on_migrate": 1 } diff --git a/beam/beam/custom/warehouse.json b/beam/beam/custom/warehouse.json index 8861a08d..79dafeae 100644 --- a/beam/beam/custom/warehouse.json +++ b/beam/beam/custom/warehouse.json @@ -6,9 +6,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "creation": "2022-06-16 09:48:36.521275", "default": null, - "docstatus": 0, "dt": "Warehouse", "fetch_if_empty": 0, "fieldname": "barcode_section", @@ -27,15 +25,11 @@ "insert_after": "pin", "label": "Barcodes", "length": 0, - "modified": "2022-06-16 09:48:36.521275", - "modified_by": "Administrator", "module": "BEAM", "name": "Warehouse-barcode_section", "no_copy": 0, "non_negative": 0, - "owner": "Administrator", "permlevel": 0, - "precision": "", "print_hide": 0, "print_hide_if_no_value": 0, "read_only": 0, @@ -51,9 +45,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "creation": "2022-06-16 09:48:36.701251", "default": null, - "docstatus": 0, "dt": "Warehouse", "fetch_if_empty": 0, "fieldname": "barcodes", @@ -70,18 +62,13 @@ "in_preview": 0, "in_standard_filter": 0, "insert_after": "barcode_section", - "label": "", "length": 0, - "modified": "2022-06-16 09:48:36.701251", - "modified_by": "Administrator", "module": "BEAM", "name": "Warehouse-barcodes", "no_copy": 0, "non_negative": 0, "options": "Item Barcode", - "owner": "Administrator", "permlevel": 0, - "precision": "", "print_hide": 0, "print_hide_if_no_value": 0, "read_only": 0, @@ -92,7 +79,6 @@ "unique": 0 } ], - "custom_perms": [], "doctype": "Warehouse", "property_setters": [], "sync_on_migrate": 1 diff --git a/beam/beam/doctype/beam_settings/beam_settings.json b/beam/beam/doctype/beam_settings/beam_settings.json index e6db82a9..b59c1ef0 100644 --- a/beam/beam/doctype/beam_settings/beam_settings.json +++ b/beam/beam/doctype/beam_settings/beam_settings.json @@ -1,5 +1,6 @@ { "actions": [], + "autoname": "field:company", "creation": "2024-03-18 17:06:58.552999", "doctype": "DocType", "engine": "InnoDB", @@ -7,6 +8,7 @@ "enable_handling_units", "scan_serial_no", "barcode_font_size", + "company", "column_break_vhpb", "qr_scale", "qr_border", @@ -56,15 +58,21 @@ "fieldname": "scan_serial_no", "fieldtype": "Check", "label": "Enable Scanning of Serial Numbers" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "unique": 1 } ], - "issingle": 1, "links": [], - "modified": "2025-08-29 10:31:24.924249", + "modified": "2026-01-02 20:34:56.013615", "modified_by": "Administrator", "module": "BEAM", "name": "BEAM Settings", - "naming_rule": "Expression (old style)", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/beam/beam/overrides/company.py b/beam/beam/overrides/company.py new file mode 100644 index 00000000..86fbbb04 --- /dev/null +++ b/beam/beam/overrides/company.py @@ -0,0 +1,11 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import frappe + +from beam.beam.doctype.beam_settings.beam_settings import create_beam_settings + + +def create_company_beam_settings(doc, method=None): + if not frappe.db.exists("BEAM Settings", {"company": doc.name}): + create_beam_settings(doc.name) diff --git a/beam/customize.py b/beam/customize.py index 45b0a066..82437f48 100644 --- a/beam/customize.py +++ b/beam/customize.py @@ -21,7 +21,9 @@ def load_customizations(): if existing_field else frappe.new_doc("Custom Field") ) - field.pop("modified") + if "modified" in field: + field.pop("modified") + {custom_field.set(key, value) for key, value in field.items()} custom_field.flags.ignore_permissions = True custom_field.flags.ignore_version = True diff --git a/beam/hooks.py b/beam/hooks.py index 9a4140a7..2a596cc0 100644 --- a/beam/hooks.py +++ b/beam/hooks.py @@ -169,6 +169,11 @@ "beam.beam.handling_unit.generate_handling_units", ], }, + "Company": { + "after_insert": [ + "beam.beam.overrides.company.create_company_beam_settings", + ], + }, } # Scheduled Tasks diff --git a/poetry.lock b/poetry.lock index b8ca4a28..46bdf14e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "colorama" @@ -6,6 +6,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -17,6 +19,7 @@ version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, @@ -96,7 +99,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "exceptiongroup" @@ -104,6 +107,8 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -118,6 +123,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -129,6 +135,7 @@ version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, @@ -140,6 +147,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -155,6 +163,7 @@ version = "8.3.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, @@ -177,6 +186,7 @@ version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, @@ -195,6 +205,7 @@ version = "1.2.1" description = "pytest plugin to run your tests in a specific order" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "pytest-order-1.2.1.tar.gz", hash = "sha256:4451bd8821ba4fa2109455a2fcc882af60ef8e53e09d244d67674be08f56eac3"}, {file = "pytest_order-1.2.1-py3-none-any.whl", hash = "sha256:c3082fc73f9ddcf13e4a22dda9bbcc2f39865bf537438a1d50fa241e028dd743"}, @@ -209,6 +220,7 @@ version = "0.15.1" description = "Create standard barcodes with Python. No external modules needed. (optional Pillow support included)." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "python-barcode-0.15.1.tar.gz", hash = "sha256:3b1825fbdb11e597466dff4286b4ea9b1e86a57717b59e563ae679726fc854de"}, {file = "python_barcode-0.15.1-py3-none-any.whl", hash = "sha256:057636fba37369c22852410c8535b36adfbeb965ddfd4e5b6924455d692e0886"}, @@ -223,6 +235,8 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -234,6 +248,7 @@ version = "0.1.0" description = "Python library to generate usable and printable ZPL2 code" optional = false python-versions = "*" +groups = ["main"] files = [] develop = false @@ -244,6 +259,6 @@ reference = "HEAD" resolved_reference = "45ffc60638814df575d9fe11c7504b1a533e4ecb" [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.10" -content-hash = "12be1441efa3267b06c916360cc5d6b3f0a9588850b760b5946904e6ac3cbdbe" +content-hash = "e35e922673042380da861bfeb9a93879cab1a8013af349af404febc89ee0255c" diff --git a/pyproject.toml b/pyproject.toml index ccaa8ae4..dd862909 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,13 @@ -[tool.poetry] +[project] name = "beam" version = "15.5.0" -authors = ["AgriTheory "] +authors = [ + { name = "AgriTheory", email = "support@agritheory.dev" } +] description = "Barcode Scanning for ERPNext" +requires-python = ">=3.10" readme = "README.md" +dynamic = ["dependencies"] [tool.poetry.dependencies] python = ">=3.10" @@ -44,4 +48,4 @@ version_variables = [ ] [tool.semantic_release.branches.version] -match = "version-15" +match = "version-15" \ No newline at end of file From 6867dc4123d778443f75c0316ff6c4f84652520d Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Fri, 6 Feb 2026 06:59:20 -0500 Subject: [PATCH 07/21] feat: make handling units respect config, add defaults (#309) --- .pre-commit-config.yaml | 2 +- beam/beam/boot.py | 19 ++++++++ beam/public/js/print/print.js | 82 ++++++++++++++++++----------------- 3 files changed, 62 insertions(+), 41 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14878e3f..3850cb85 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: - tomli - repo: https://github.com/agritheory/test_utils - rev: v1.7.1 + rev: v1.12.0 hooks: - id: update_pre_commit_config - id: validate_copyright diff --git a/beam/beam/boot.py b/beam/beam/boot.py index 494ed8c1..22cb4b11 100644 --- a/beam/beam/boot.py +++ b/beam/beam/boot.py @@ -1,9 +1,28 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt +import frappe from beam.beam.scan.config import get_scan_doctypes def boot_session(bootinfo): bootinfo.beam = get_scan_doctypes() + bootinfo.beam["settings"] = get_beam_settings() + bootinfo.beam["default_hu_print_format"] = frappe.get_meta("Handling Unit").get( + "default_print_format" + ) + + +def get_beam_settings(): + """Get BEAM Settings for all companies, keyed by company name.""" + settings = {} + beam_settings = frappe.get_all( + "BEAM Settings", + fields=["company", "enable_handling_units"], + ) + for setting in beam_settings: + settings[setting.company] = { + "enable_handling_units": setting.enable_handling_units, + } + return settings diff --git a/beam/public/js/print/print.js b/beam/public/js/print/print.js index 70a54a49..238ffb54 100644 --- a/beam/public/js/print/print.js +++ b/beam/public/js/print/print.js @@ -40,47 +40,49 @@ function custom_print_button(frm) { if (frm.doc.docstatus != 1) { return } - frappe.db.get_value('BEAM Settings', { company: frm.doc.company }, 'enable_handling_units', r => { - if (r && r.enable_handling_units) { - frm.add_custom_button(__(' Print Handling Unit'), () => { - let d = new frappe.ui.Dialog({ - title: __('Select Printer Setting'), - fields: [ - { - label: __('Printer Setting'), - fieldname: 'printer_setting', - fieldtype: 'Link', - options: 'Network Printer Settings', - }, - { - label: __('Printer Format'), - fieldname: 'print_format', - fieldtype: 'Link', - options: 'Print Format', - get_query: function () { - return { - filters: { doc_type: 'Handling Unit' }, - } - }, - }, - ], - primary_action_label: 'Select', - primary_action(selection) { - d.hide() - frappe.call({ - method: 'beam.beam.printing.print_handling_units', - args: { - doctype: frm.doc.doctype, - name: frm.doc.name, - printer_setting: selection.printer_setting, - print_format: selection.print_format, - doc: frm.doc, - }, - }) + const beam_settings = frappe.boot.beam?.settings?.[frm.doc.company] + if (!beam_settings?.enable_handling_units) { + return + } + frm.add_custom_button(__(' Print Handling Unit'), () => { + let d = new frappe.ui.Dialog({ + title: __('Select Printer Setting'), + fields: [ + { + label: __('Printer Setting'), + fieldname: 'printer_setting', + fieldtype: 'Link', + options: 'Network Printer Settings', + default: frappe.defaults.get_user_default('Network Printer Settings'), + }, + { + label: __('Print Format'), + fieldname: 'print_format', + fieldtype: 'Link', + options: 'Print Format', + default: frappe.boot.beam?.default_hu_print_format, + get_query: function () { + return { + filters: { doc_type: 'Handling Unit' }, + } + }, + }, + ], + primary_action_label: 'Select', + primary_action(selection) { + d.hide() + frappe.call({ + method: 'beam.beam.printing.print_handling_units', + args: { + doctype: frm.doc.doctype, + name: frm.doc.name, + printer_setting: selection.printer_setting, + print_format: selection.print_format, + doc: frm.doc, }, }) - d.show() - }) - } + }, + }) + d.show() }) } From c35a70f9320d39a4a80ad2d2063ccc2480a79cea Mon Sep 17 00:00:00 2001 From: AgriTheory Date: Fri, 6 Feb 2026 12:00:08 +0000 Subject: [PATCH 08/21] 15.6.0 Automatically generated by python-semantic-release --- beam/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beam/__init__.py b/beam/__init__.py index 304047bb..77be79cc 100644 --- a/beam/__init__.py +++ b/beam/__init__.py @@ -1,4 +1,4 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt -__version__ = "15.5.0" +__version__ = "15.6.0" diff --git a/pyproject.toml b/pyproject.toml index dd862909..3a4d7d44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beam" -version = "15.5.0" +version = "15.6.0" authors = [ { name = "AgriTheory", email = "support@agritheory.dev" } ] From 9aa5111d5154543cb1f075433c38af2f15b5527e Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Fri, 6 Feb 2026 11:04:28 -0500 Subject: [PATCH 09/21] Print standard format (#314) Co-authored-by: Lautaro Juarez <38353324+lauty95@users.noreply.github.com> Co-authored-by: lauty95 --- .pre-commit-config.yaml | 2 +- beam/beam/custom/item.json | 2 +- beam/beam/overrides/stock_entry.py | 23 ++++++++ beam/beam/printing.py | 12 +++-- beam/beam/scan/__init__.py | 9 +++- beam/tests/setup.py | 14 +++-- beam/tests/test_handling_unit.py | 84 ++++++++++++++++++++++++++++- beam/tests/test_printing.py | 85 ++++++++++++++++++++++++++++++ beam/tests/test_serial_number.py | 11 ++-- 9 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 beam/tests/test_printing.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3850cb85..e77ac14d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: - tomli - repo: https://github.com/agritheory/test_utils - rev: v1.12.0 + rev: v1.13.0 hooks: - id: update_pre_commit_config - id: validate_copyright diff --git a/beam/beam/custom/item.json b/beam/beam/custom/item.json index 03099d33..a342fc85 100644 --- a/beam/beam/custom/item.json +++ b/beam/beam/custom/item.json @@ -6,7 +6,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "default": "1", + "default": "0", "dt": "Item", "fetch_if_empty": 0, "fieldname": "enable_handling_unit", diff --git a/beam/beam/overrides/stock_entry.py b/beam/beam/overrides/stock_entry.py index 10c4c3b8..d23c2546 100644 --- a/beam/beam/overrides/stock_entry.py +++ b/beam/beam/overrides/stock_entry.py @@ -19,6 +19,29 @@ def update_stock_ledger(self): finished_item_row = self.get_finished_item_row() self.get_sle_for_source_warehouse(sl_entries, finished_item_row) self.get_sle_for_target_warehouse(sl_entries, finished_item_row) + + # Ensure handling_unit is set on SLE entries if enabled + if settings.enable_handling_units: + for sle in sl_entries: + if hasattr(sle, "get") and "voucher_detail_no" in sle: + item_row = next( + (item for item in self.items if item.name == sle.get("voucher_detail_no")), None + ) + if item_row: + # For source warehouse (consumption), use handling_unit + if ( + sle.get("warehouse") == item_row.s_warehouse + and hasattr(item_row, "handling_unit") + and item_row.handling_unit + ): + sle["handling_unit"] = item_row.handling_unit + # For target warehouse (receipt), use to_handling_unit if it exists, otherwise handling_unit + elif sle.get("warehouse") == item_row.t_warehouse: + if hasattr(item_row, "to_handling_unit") and item_row.to_handling_unit: + sle["handling_unit"] = item_row.to_handling_unit + elif hasattr(item_row, "handling_unit") and item_row.handling_unit: + sle["handling_unit"] = item_row.handling_unit + if self.docstatus == 2: sl_entries.reverse() self.make_sl_entries(sl_entries) diff --git a/beam/beam/printing.py b/beam/beam/printing.py index fb126ad7..6708d041 100644 --- a/beam/beam/printing.py +++ b/beam/beam/printing.py @@ -31,9 +31,14 @@ def print_by_server( ): print_settings = frappe.get_doc("Network Printer Settings", printer_setting) if isinstance(doc, str): - doc = frappe._dict(json.loads(doc)) + _doc = frappe._dict(json.loads(doc)) + doc = frappe.get_doc(_doc.doctype, _doc.name) + doc.update(_doc) if not print_format: print_format = frappe.get_meta(doctype).get("default_print_format") + # Default to "Standard" print format if still empty + if not print_format: + print_format = "Standard" print_format = frappe.get_doc("Print Format", print_format) try: cups.setServer(print_settings.server_ip) @@ -99,8 +104,9 @@ def print_handling_units( doctype=None, name=None, printer_setting=None, print_format=None, doc=None ): if isinstance(doc, str): - doc = frappe._dict(json.loads(doc)) - + _doc = frappe._dict(json.loads(doc)) + doc = frappe.get_doc(_doc.doctype, _doc.name) + doc.update(_doc) for row in doc.get("items"): if not row.get("handling_unit"): continue diff --git a/beam/beam/scan/__init__.py b/beam/beam/scan/__init__.py index c3231055..87a927ca 100644 --- a/beam/beam/scan/__init__.py +++ b/beam/beam/scan/__init__.py @@ -34,7 +34,12 @@ def scan( def get_barcode_context(barcode: str) -> frappe._dict | None: - settings = frappe.get_cached_doc("BEAM Settings", "BEAM Settings") + # Get BEAM Settings for default company + company = frappe.defaults.get_defaults().get("company") + settings = None + if company and frappe.db.exists("BEAM Settings", {"company": company}): + settings = frappe.get_cached_doc("BEAM Settings", company) + item_barcode = frappe.db.get_value( "Item Barcode", {"barcode": barcode}, ["parent", "parenttype"], as_dict=True ) @@ -45,7 +50,7 @@ def get_barcode_context(barcode: str) -> frappe._dict | None: "barcode": barcode, } ) - elif not item_barcode and settings.scan_serial_no: + elif not item_barcode and settings and settings.scan_serial_no: serial_no_table = frappe.qb.DocType("Serial No") bundle_entry_table = frappe.qb.DocType("Serial and Batch Entry") bundle_table = frappe.qb.DocType("Serial and Batch Bundle") diff --git a/beam/tests/setup.py b/beam/tests/setup.py index cec6e367..4cf7dacb 100644 --- a/beam/tests/setup.py +++ b/beam/tests/setup.py @@ -554,9 +554,12 @@ def create_production_plan(settings, prod_plan_from_doc): wo.save() wo.submit() frappe.db.set_value("Work Order", wo.name, "creation", start_time) - job_cards = frappe.get_all("Job Card", {"work_order": wo.name}) - for job_card in job_cards: - job_card = frappe.get_doc("Job Card", job_card) + # Get job cards and sort by sequence_id to process in order + job_cards = frappe.get_all( + "Job Card", {"work_order": wo.name}, ["name", "sequence_id"], order_by="sequence_id asc" + ) + for jc in job_cards: + job_card = frappe.get_doc("Job Card", jc.name) batch_size, total_operation_time = frappe.get_value( "Operation", job_card.operation, ["batch_size", "total_operation_time"] ) @@ -567,10 +570,13 @@ def create_production_plan(settings, prod_plan_from_doc): "from_time": start_time, "to_time": start_time + datetime.timedelta(minutes=time_in_mins), "time_in_mins": time_in_mins, - "remaining_time_in_mins": time_in_mins, + "completed_qty": wo.qty, }, ) + # Complete the job card + job_card.total_completed_qty = wo.qty job_card.save() + job_card.submit() start_time = job_card.time_logs[0].to_time + datetime.timedelta(minutes=2) diff --git a/beam/tests/test_handling_unit.py b/beam/tests/test_handling_unit.py index 0b27ebef..6a97e2a9 100644 --- a/beam/tests/test_handling_unit.py +++ b/beam/tests/test_handling_unit.py @@ -22,6 +22,84 @@ def submit_all_purchase_receipts(): pr.submit() +def test_enable_handling_units_setting(): + """Test that enable_handling_units setting controls whether handling units are assigned to SLEs""" + company = frappe.defaults.get_defaults().get("company") + + # Test with enable_handling_units = False (default) + beam_settings = frappe.get_doc("BEAM Settings", {"company": company}) + original_value = beam_settings.enable_handling_units + beam_settings.enable_handling_units = 0 + beam_settings.save() + + try: + se_disabled = frappe.new_doc("Stock Entry") + se_disabled.stock_entry_type = se_disabled.purpose = "Material Receipt" + se_disabled.company = company + se_disabled.append( + "items", + { + "item_code": "Ambrosia Pie", + "qty": 10, + "t_warehouse": "Baked Goods - APC", + "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"), + }, + ) + se_disabled.save() + se_disabled.submit() + + # When disabled, handling_unit should NOT be generated + item_row = se_disabled.items[0] + assert ( + not item_row.handling_unit + ), f"Item row should not have handling_unit when setting is disabled, but got: {item_row.handling_unit}" + + # Check SLE - handling_unit should also NOT be set + sle_disabled = frappe.get_doc("Stock Ledger Entry", {"voucher_detail_no": item_row.name}) + assert ( + not sle_disabled.handling_unit or sle_disabled.handling_unit == "" + ), f"SLE should not have handling_unit when enable_handling_units is disabled, but got: {sle_disabled.handling_unit}" + + # Now test with enable_handling_units = True + beam_settings.enable_handling_units = 1 + beam_settings.save() + + se_enabled = frappe.new_doc("Stock Entry") + se_enabled.stock_entry_type = se_enabled.purpose = "Material Receipt" + se_enabled.company = company + se_enabled.append( + "items", + { + "item_code": "Ambrosia Pie", + "qty": 10, + "t_warehouse": "Baked Goods - APC", + "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"), + }, + ) + se_enabled.save() + se_enabled.submit() + + # When enabled, handling_unit should be generated on item row + item_row_enabled = se_enabled.items[0] + assert ( + item_row_enabled.handling_unit + ), "Item row should have handling_unit when setting is enabled" + + # Check SLE - handling_unit SHOULD be set when enabled + sle_enabled = frappe.get_doc("Stock Ledger Entry", {"voucher_detail_no": item_row_enabled.name}) + assert ( + sle_enabled.handling_unit + ), "SLE should have handling_unit when enable_handling_units is enabled" + assert ( + sle_enabled.handling_unit == item_row_enabled.handling_unit + ), f"SLE handling_unit should match item row: {sle_enabled.handling_unit} != {item_row_enabled.handling_unit}" + + finally: + # Restore original setting + beam_settings.enable_handling_units = original_value + beam_settings.save() + + @pytest.mark.order(1) def test_purchase_receipt_handling_unit_generation(): for pr in frappe.get_all("Purchase Receipt"): @@ -463,7 +541,11 @@ def test_stock_entry_material_transfer(): "Item", row.item_code, "enable_handling_unit" ): continue - sle = frappe.get_doc("Stock Ledger Entry", {"handling_unit": row.handling_unit}) + # For Material Transfer, there are two SLEs - one for source (negative) and one for target (positive) + # Get the source warehouse SLE (the one consuming from the handling unit) + sle = frappe.get_doc( + "Stock Ledger Entry", {"handling_unit": row.handling_unit, "warehouse": row.s_warehouse} + ) hu = get_handling_unit(str(row.handling_unit)) assert row.transfer_qty == abs(sle.actual_qty) assert hu.stock_qty == 95 # net qty diff --git a/beam/tests/test_printing.py b/beam/tests/test_printing.py new file mode 100644 index 00000000..e2f6f6a0 --- /dev/null +++ b/beam/tests/test_printing.py @@ -0,0 +1,85 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt + +from unittest.mock import Mock, patch + +import frappe +from frappe.exceptions import DoesNotExistError + +from beam.beam.printing import print_by_server + + +def test_print_by_server_empty_string_uses_standard(): + """Empty print_format should default to Standard""" + mock_cups = Mock() + mock_cups.IPPError = Exception + with patch("beam.beam.printing.cups", mock_cups): + try: + print_by_server( + doctype="Item", + name="Ambrosia Pie", + printer_setting="Kitchen Printer", + print_format="", + ) + except DoesNotExistError as e: + # Should fail trying to get "Standard" print format + assert "Standard" in str(e) + + +def test_print_by_server_none_uses_standard(): + """None print_format should default to Standard""" + mock_cups = Mock() + mock_cups.IPPError = Exception + with patch("beam.beam.printing.cups", mock_cups): + try: + print_by_server( + doctype="Item", + name="Ambrosia Pie", + printer_setting="Kitchen Printer", + print_format=None, + ) + except DoesNotExistError as e: + # Should fail trying to get "Standard" print format + assert "Standard" in str(e) + + +def test_print_by_server_explicit_format(): + """Explicit print_format should be used""" + from beam.beam.printing import print_by_server + + mock_cups = Mock() + mock_cups.IPPError = Exception + with patch("beam.beam.printing.cups", mock_cups): + try: + print_by_server( + doctype="Item", + name="Ambrosia Pie", + printer_setting="Kitchen Printer", + print_format="Item Barcode", + ) + except Exception as e: + # Should NOT fail on "Standard" - should use explicit format + assert "Standard" not in str(e), "Should use explicit format, not Standard" + + +def test_print_by_server_with_serialized_doc(): + """Serialized doc should be properly deserialized as full document instance""" + # Get a real item doc and serialize it like the frontend would + item = frappe.get_doc("Item", "Ambrosia Pie") + serialized_doc = frappe.as_json(item.as_dict()) + + mock_cups = Mock() + mock_cups.IPPError = Exception + with patch("beam.beam.printing.cups", mock_cups): + try: + print_by_server( + doctype="Item", + name="Ambrosia Pie", + printer_setting="Kitchen Printer", + print_format="Item Barcode", + doc=serialized_doc, # Pass as JSON string + ) + except Exception as e: + # Should not fail with AttributeError about 'in_print' + assert "in_print" not in str(e) + assert not isinstance(e, AttributeError) diff --git a/beam/tests/test_serial_number.py b/beam/tests/test_serial_number.py index 24db1089..a304a8ba 100644 --- a/beam/tests/test_serial_number.py +++ b/beam/tests/test_serial_number.py @@ -40,7 +40,10 @@ def test_serial_number_scan(): pr.submit() # Serial No scanning disabled - settings = frappe.get_doc("BEAM Settings", "BEAM Settings") + company = frappe.defaults.get_defaults().get("company") + settings = frappe.get_doc("BEAM Settings", {"company": company}) + settings.scan_serial_no = 0 + settings.save() assert settings.scan_serial_no == 0 scan = frappe.call( "beam.beam.scan.scan", @@ -84,7 +87,8 @@ def test_serial_number_scan(): pi.save() pi.submit() - settings = frappe.get_doc("BEAM Settings", "BEAM Settings") + company = frappe.defaults.get_defaults().get("company") + settings = frappe.get_doc("BEAM Settings", {"company": company}) settings.scan_serial_no = 1 settings.save() scan = frappe.call( @@ -117,7 +121,8 @@ def test_serial_number_scan(): dn.save() dn.submit() - settings = frappe.get_doc("BEAM Settings", "BEAM Settings") + company = frappe.defaults.get_defaults().get("company") + settings = frappe.get_doc("BEAM Settings", {"company": company}) settings.scan_serial_no = 1 settings.save() scan = frappe.call( From de325480f1ff8f13d61363b7673d6f8f5bc8809f Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Sun, 8 Feb 2026 08:57:56 -0500 Subject: [PATCH 10/21] fix: fix recombine UI bug, add tests and better documentation (#315) --- beam/beam/overrides/stock_entry.py | 33 +++- beam/docs/handling_unit.md | 34 ++++- beam/public/js/stock_entry_custom.js | 80 ++++++++-- beam/tests/test_handling_unit.py | 220 +++++++++++++++++++++++++++ 4 files changed, 356 insertions(+), 11 deletions(-) diff --git a/beam/beam/overrides/stock_entry.py b/beam/beam/overrides/stock_entry.py index d23c2546..24297311 100644 --- a/beam/beam/overrides/stock_entry.py +++ b/beam/beam/overrides/stock_entry.py @@ -53,7 +53,12 @@ def update_stock_ledger(self): def make_handling_unit_sles(self): hu_sles = [] for d in self.get("items"): - if self.docstatus == 2 and not d.recombine_on_cancel and d.handling_unit and d.to_handling_unit: + # Only process when cancelling AND user wants to keep separate (NOT recombine) + if self.docstatus != 2 or d.recombine_on_cancel or not d.handling_unit: + continue + + if d.handling_unit and d.to_handling_unit: + # Material Transfer types: both HUs on the same row sle = self.get_sl_entries( d, { @@ -76,6 +81,32 @@ def make_handling_unit_sles(self): _sle["handling_unit"] = d.to_handling_unit _sle["is_cancelled"] = 0 hu_sles.append(_sle) + elif d.s_warehouse and not d.t_warehouse: + # Repack/Manufacture source row: re-consume from source HU + sle = self.get_sl_entries( + d, + { + "warehouse": cstr(d.s_warehouse), + "actual_qty": -flt(d.transfer_qty), + "incoming_rate": flt(d.valuation_rate), + }, + ) + sle["handling_unit"] = d.handling_unit + sle["is_cancelled"] = 0 + hu_sles.append(sle) + elif d.t_warehouse and not d.s_warehouse: + # Repack/Manufacture target row: re-add to target HU + sle = self.get_sl_entries( + d, + { + "warehouse": cstr(d.t_warehouse), + "actual_qty": flt(d.transfer_qty), + "incoming_rate": flt(d.valuation_rate), + }, + ) + sle["handling_unit"] = d.handling_unit + sle["is_cancelled"] = 0 + hu_sles.append(sle) return hu_sles diff --git a/beam/docs/handling_unit.md b/beam/docs/handling_unit.md index d13521c3..1fac37c2 100644 --- a/beam/docs/handling_unit.md +++ b/beam/docs/handling_unit.md @@ -76,10 +76,26 @@ When material is transferred from one warehouse to another, it will generate a n | Cocoplum | Work In Progress | 456 | 20 Ea | -When cancelling a Stock Entry, the user will be given an option to re-combine or let handling units remain tracked separately. +#### Cancelling Material Transfer Entries + +When cancelling a Material Transfer Stock Entry (including Send to Subcontractor and Material Transfer for Manufacture), a dialog appears asking whether to recombine handling units or keep them tracked separately. ![Screen shot of the recombine dialog](./assets/recombine.png) +The dialog shows each source handling unit along with its corresponding target handling unit that was created during the transfer. By default, all rows are pre-selected for recombination (the recommended action). + +**Recombine (Default):** When rows are selected and "Cancel and Recombine" is clicked: +- The source and target handling units are merged back together +- The original handling unit retains its full quantity as if the transfer never happened +- The target handling unit is removed from inventory +- This is the typical choice when correcting errors or undoing temporary transfers + +**Keep Separate:** When rows are unchecked before clicking "Cancel and Recombine": +- Both handling units remain in the system with their respective quantities +- Stock ledger entries are created to restore the quantities in both warehouses +- The handling units continue to be tracked independently +- Useful when you want to maintain the split for future reference or traceability + ### Repack and Manufacture In the case of a Repack, Material Issue or Material Consumption for Manufacture, a new Handling Unit is generated for the new quantities. @@ -98,6 +114,22 @@ In a case where less than the total quantity associated with a Handling Unit is | Cocoplum Puree | Work In Progress | 012 | 1 liter | | Cocoplum | Scrap | | 1 Ea | +#### Cancelling Repack and Manufacture Entries + +Similar to Material Transfer entries, when cancelling a Repack or Manufacture Stock Entry, a dialog appears to choose the recombine behavior. The dialog shows each consumed (source) handling unit paired with its corresponding produced (target) handling unit. All rows are pre-selected for recombination by default. + +**Recombine (Default):** When rows are selected: +- The consumed handling unit is restored to its original quantity +- The produced handling unit is removed from inventory +- The transformation is completely reversed +- Best for correcting data entry errors or voiding incorrect manufacturing entries + +**Keep Separate:** When rows are unchecked: +- The consumed handling unit receives its quantity back +- The produced handling unit also retains its quantity +- Both handling units coexist in inventory +- Useful for maintaining audit trails when a production run needs to be reversed but you want to preserve the separate handling unit records for compliance or tracking purposes + #### BOM Scrap Item In a Manufacturing or Repack Stock Entry, scrap items can be toggled to create a Handling Unit corresponding with their scrap quantity. This can be changed after a BOM is submitted. diff --git a/beam/public/js/stock_entry_custom.js b/beam/public/js/stock_entry_custom.js index 8b6d6531..09f68642 100644 --- a/beam/public/js/stock_entry_custom.js +++ b/beam/public/js/stock_entry_custom.js @@ -23,8 +23,8 @@ frappe.ui.form.on('Stock Entry', { async function show_handling_unit_recombine_dialog(frm) { const data = await get_handling_units(frm) - if (!data) { - return resolve({}) + if (!data || !data.length) { + return [] } let fields = [ { @@ -35,6 +35,14 @@ async function show_handling_unit_recombine_dialog(frm) { disabled: 0, hidden: 1, }, + { + fieldtype: 'Data', + fieldname: 'target_row_name', + in_list_view: 0, + read_only: 1, + disabled: 0, + hidden: 1, + }, { fieldtype: 'Link', fieldname: 'item_code', @@ -43,6 +51,7 @@ async function show_handling_unit_recombine_dialog(frm) { read_only: 1, disabled: 0, label: __('Item Code'), + columns: 2, }, { fieldtype: 'Data', @@ -57,6 +66,7 @@ async function show_handling_unit_recombine_dialog(frm) { label: __('Handling Unit'), in_list_view: 1, read_only: 1, + columns: 2, }, { fieldtype: 'Float', @@ -64,6 +74,7 @@ async function show_handling_unit_recombine_dialog(frm) { label: __('Remaining Qty'), in_list_view: 1, read_only: 1, + columns: 1, }, { fieldtype: 'Data', @@ -71,6 +82,7 @@ async function show_handling_unit_recombine_dialog(frm) { label: __('Handling Unit to recombine'), in_list_view: 1, read_only: 1, + columns: 2, }, { fieldtype: 'Float', @@ -78,6 +90,7 @@ async function show_handling_unit_recombine_dialog(frm) { label: __('Transferred Qty'), in_list_view: 1, read_only: 1, + columns: 1, }, ] @@ -88,10 +101,8 @@ async function show_handling_unit_recombine_dialog(frm) { { fieldname: 'handling_units', fieldtype: 'Table', - in_place_edit: false, - editable_grid: false, cannot_add_rows: true, - cannot_delete_rows: true, + cannot_delete_rows: false, reqd: 1, data: data, get_data: () => { @@ -104,9 +115,14 @@ async function show_handling_unit_recombine_dialog(frm) { }, ], primary_action: () => { - let to_recombine = dialog.fields_dict.handling_units.grid.get_selected_children().map(row => { - return row.row_name - }) + let selected = dialog.fields_dict.handling_units.grid.get_selected_children() + let to_recombine = [] + for (let row of selected) { + to_recombine.push(row.row_name) + if (row.target_row_name) { + to_recombine.push(row.target_row_name) + } + } dialog.hide() return resolve(to_recombine) }, @@ -114,14 +130,39 @@ async function show_handling_unit_recombine_dialog(frm) { size: 'extra-large', }) dialog.show() + // Pre-check all rows so recombine is the default behavior + setTimeout(() => { + const grid = dialog.fields_dict.handling_units.grid + // Enable and check all rows + if (grid.wrapper) { + grid.wrapper.find('.grid-row-check').prop('disabled', false).prop('checked', true) + // Hide the Delete button + grid.wrapper.find('.grid-remove-rows').hide() + } + grid.grid_rows?.forEach(row => { + if (row.doc) { + row.doc.__checked = 1 + if (row.row) { + row.row.find('.grid-row-check').prop('disabled', false).prop('checked', true) + } + } + }) + grid.refresh() + }, 200) dialog.get_close_btn() }) } async function get_handling_units(frm) { let handling_units = [] + const transfer_types = ['Material Transfer', 'Send to Subcontractor', 'Material Transfer for Manufacture'] + for (const row of frm.doc.items) { - if (row.handling_unit && row.to_handling_unit) { + if (!row.handling_unit) continue + + if (transfer_types.includes(frm.doc.purpose)) { + // Material Transfer types: source and destination HU are on the same row + if (!row.to_handling_unit) continue let remaining_qty = await get_handling_unit_stock_qty(frm.doc.name, row.handling_unit, row.s_warehouse) handling_units.push({ row_name: row.name, @@ -132,8 +173,25 @@ async function get_handling_units(frm) { remaining_qty: remaining_qty, transferred_qty: row.qty, }) + } else { + // Repack/Manufacture/etc: source and target HUs are on separate rows + // Only show source rows (those with s_warehouse); pair with matching target row + if (!row.s_warehouse) continue + let target_row = frm.doc.items.find(r => r.t_warehouse && r.handling_unit && r.item_code === row.item_code) + let remaining_qty = await get_handling_unit_stock_qty(frm.doc.name, row.handling_unit, row.s_warehouse) + handling_units.push({ + row_name: row.name, + target_row_name: target_row?.name || '', + item_code: row.item_code, + item_name: row.item_name, + handling_unit: row.handling_unit, + to_handling_unit: target_row?.handling_unit || '', + remaining_qty: remaining_qty, + transferred_qty: row.transfer_qty || row.qty, + }) } } + return handling_units } async function get_handling_unit_stock_qty(name, handling_unit, s_warehouse) { @@ -147,6 +205,10 @@ async function get_handling_unit_stock_qty(name, handling_unit, s_warehouse) { //re combine async function set_recombine_handling_units(frm) { + // const beam_settings = frappe.boot.beam?.settings?.[frm.doc.company] + // if (!beam_settings?.enable_handling_units) { + // return + // } let to_recombine = await show_handling_unit_recombine_dialog(frm) await frappe.xcall('beam.beam.overrides.stock_entry.set_rows_to_recombine', { docname: frm.doc.name, diff --git a/beam/tests/test_handling_unit.py b/beam/tests/test_handling_unit.py index 6a97e2a9..d1374dfc 100644 --- a/beam/tests/test_handling_unit.py +++ b/beam/tests/test_handling_unit.py @@ -763,3 +763,223 @@ def test_handling_units_overconsumption_in_delivery_note(): f"Row #1: Handling Unit for Ambrosia Pie cannot be more than {hu.stock_qty} {hu.stock_uom}. You have {row_qty:.1f} {row_stock_uom}" in exc_info.value.args[0] ) + + +@pytest.mark.order(15) +def test_repack_cancel_without_recombine(): + """Test cancelling a Repack Stock Entry without recombining handling units""" + # Create a material receipt with a known handling unit + se_receipt = frappe.new_doc("Stock Entry") + se_receipt.stock_entry_type = se_receipt.purpose = "Material Receipt" + se_receipt.append( + "items", + { + "item_code": "Parchment Paper", + "qty": 100, + "t_warehouse": "Storeroom - APC", + "basic_rate": frappe.get_value( + "Item Price", {"item_code": "Parchment Paper"}, "price_list_rate" + ), + }, + ) + se_receipt.save() + se_receipt.submit() + source_hu = se_receipt.items[0].handling_unit + + # Create a repack entry + se_repack = frappe.new_doc("Stock Entry") + se_repack.stock_entry_type = se_repack.purpose = "Repack" + se_repack.append( + "items", + { + "item_code": "Parchment Paper", + "qty": 1, + "uom": "Box", + "conversion_factor": 100, + "stock_qty": 100, + "actual_qty": 100, + "transfer_qty": 100, + "s_warehouse": "Storeroom - APC", + "handling_unit": source_hu, + }, + ) + se_repack.append( + "items", + { + "item_code": "Parchment Paper", + "uom": "Nos", + "qty": 100, + "actual_qty": 100, + "transfer_qty": 100, + "t_warehouse": "Storeroom - APC", + }, + ) + se_repack.save() + se_repack.submit() + + source_row = se_repack.items[0] + target_row = se_repack.items[1] + target_hu = target_row.handling_unit + + # Verify initial state + source_hu_doc = get_handling_unit(source_hu) + target_hu_doc = get_handling_unit(target_hu) + assert source_hu_doc.stock_qty == 0 # consumed + assert target_hu_doc.stock_qty == 100 # created + + # Cancel WITHOUT recombine (don't set recombine_on_cancel) + se_repack.cancel() + + # After cancel without recombine: + # - Source HU should have qty 0 (consumed stays consumed) + # - Target HU should still exist with qty 100 (produced stays produced) + # This "keep separate" behavior maintains the split in cancelled state + source_hu_doc = get_handling_unit(source_hu) + target_hu_doc = get_handling_unit(target_hu) + assert source_hu_doc.stock_qty == 0 # consumed + assert target_hu_doc.stock_qty == 100 # produced + + +@pytest.mark.order(16) +def test_repack_cancel_with_recombine(): + """Test cancelling a Repack Stock Entry WITH recombining handling units""" + # Create a material receipt with a known handling unit + se_receipt = frappe.new_doc("Stock Entry") + se_receipt.stock_entry_type = se_receipt.purpose = "Material Receipt" + se_receipt.append( + "items", + { + "item_code": "Parchment Paper", + "qty": 100, + "t_warehouse": "Storeroom - APC", + "basic_rate": frappe.get_value( + "Item Price", {"item_code": "Parchment Paper"}, "price_list_rate" + ), + }, + ) + se_receipt.save() + se_receipt.submit() + source_hu = se_receipt.items[0].handling_unit + + # Create a repack entry + se_repack = frappe.new_doc("Stock Entry") + se_repack.stock_entry_type = se_repack.purpose = "Repack" + se_repack.append( + "items", + { + "item_code": "Parchment Paper", + "qty": 1, + "uom": "Box", + "conversion_factor": 100, + "stock_qty": 100, + "actual_qty": 100, + "transfer_qty": 100, + "s_warehouse": "Storeroom - APC", + "handling_unit": source_hu, + }, + ) + se_repack.append( + "items", + { + "item_code": "Parchment Paper", + "uom": "Nos", + "qty": 100, + "actual_qty": 100, + "transfer_qty": 100, + "t_warehouse": "Storeroom - APC", + }, + ) + se_repack.save() + se_repack.submit() + + source_row = se_repack.items[0] + target_row = se_repack.items[1] + target_hu = target_row.handling_unit + + # Set recombine_on_cancel on BOTH rows (as the frontend does) + source_row.db_set("recombine_on_cancel", True) + target_row.db_set("recombine_on_cancel", True) + + # Cancel WITH recombine + se_repack.reload() + se_repack.cancel() + + # After cancel with recombine: + # - Source HU should NOT get additional entries (recombine prevents split) + # - Target HU should NOT exist (was recombined back) + source_hu_doc = get_handling_unit(source_hu) + target_hu_doc = get_handling_unit(target_hu) + + # Source HU should have the original quantity (no split entries added) + assert source_hu_doc.stock_qty == 100 + # Target HU should be empty/zero (recombined back to source) + assert target_hu_doc is None or target_hu_doc.stock_qty == 0 + + +@pytest.mark.order(17) +def test_material_transfer_cancel_without_recombine(): + """Test cancelling a Material Transfer Stock Entry without recombining handling units""" + # Create a material receipt + se_receipt = frappe.new_doc("Stock Entry") + se_receipt.stock_entry_type = se_receipt.purpose = "Material Receipt" + se_receipt.append( + "items", + { + "item_code": "Parchment Paper", + "qty": 100, + "t_warehouse": "Storeroom - APC", + "basic_rate": frappe.get_value( + "Item Price", {"item_code": "Parchment Paper"}, "price_list_rate" + ), + }, + ) + se_receipt.save() + se_receipt.submit() + source_hu = se_receipt.items[0].handling_unit + + # Create a material transfer + se_transfer = frappe.new_doc("Stock Entry") + se_transfer.stock_entry_type = se_transfer.purpose = "Material Transfer" + se_transfer.company = frappe.defaults.get_defaults().get("company") + + scan = frappe.call( + "beam.beam.scan.scan", + **{ + "barcode": str(source_hu), + "context": {"frm": "Stock Entry", "doc": se_transfer.as_dict()}, + "current_qty": 1, + }, + ) + se_transfer.append( + "items", + { + **scan[0]["context"], + "qty": 50, + "actual_qty": 50, + "transfer_qty": 50, + "s_warehouse": "Storeroom - APC", + "t_warehouse": "Kitchen - APC", + }, + ) + se_transfer.save() + se_transfer.submit() + + transfer_row = se_transfer.items[0] + target_hu = transfer_row.to_handling_unit + + # Verify initial state + source_hu_doc = get_handling_unit(source_hu) + target_hu_doc = get_handling_unit(target_hu) + assert source_hu_doc.stock_qty == 50 # remaining in source + assert target_hu_doc.stock_qty == 50 # transferred to target + + # Cancel WITHOUT recombine + se_transfer.cancel() + + # After cancel without recombine: + # - Source HU should be restored + # - Target HU should also be restored (both persist separately) + source_hu_doc = get_handling_unit(source_hu) + target_hu_doc = get_handling_unit(target_hu) + assert source_hu_doc.stock_qty == 50 # restored in source warehouse + assert target_hu_doc.stock_qty == 50 # restored in target warehouse From e8e8b8761a8e2c930bfe6bfb276ab77be03b32ed Mon Sep 17 00:00:00 2001 From: AgriTheory Date: Sun, 8 Feb 2026 13:58:49 +0000 Subject: [PATCH 11/21] 15.6.1 Automatically generated by python-semantic-release --- beam/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beam/__init__.py b/beam/__init__.py index 77be79cc..a066c624 100644 --- a/beam/__init__.py +++ b/beam/__init__.py @@ -1,4 +1,4 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt -__version__ = "15.6.0" +__version__ = "15.6.1" diff --git a/pyproject.toml b/pyproject.toml index 3a4d7d44..fa9d66e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beam" -version = "15.6.0" +version = "15.6.1" authors = [ { name = "AgriTheory", email = "support@agritheory.dev" } ] From 9ebe7ce29dd3e90328742c2acddb45404299aad7 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Sat, 21 Feb 2026 20:10:14 -0500 Subject: [PATCH 12/21] chore: update pyproject and precommit (#324) --- .github/validate_customizations.py | 169 ----------------------- .github/workflows/generate-changelog.yml | 23 +++ .github/workflows/overrides.yml | 20 +++ .pre-commit-config.yaml | 54 ++++---- beam/customize.py | 47 ------- beam/docs/form.md | 5 + beam/docs/handling_unit.md | 5 + beam/docs/hooks.md | 5 + beam/docs/hu_traceability_report.md | 5 + beam/docs/index.md | 5 + beam/docs/listview.md | 5 + beam/docs/matrix.md | 5 + beam/docs/print_server.md | 5 + beam/docs/testing.md | 5 + beam/docs/zebra_printing.md | 5 + beam/hooks.py | 1 + beam/install.py | 2 - pyproject.toml | 11 +- 18 files changed, 130 insertions(+), 247 deletions(-) delete mode 100644 .github/validate_customizations.py create mode 100644 .github/workflows/generate-changelog.yml create mode 100644 .github/workflows/overrides.yml delete mode 100644 beam/customize.py diff --git a/.github/validate_customizations.py b/.github/validate_customizations.py deleted file mode 100644 index 89cbf720..00000000 --- a/.github/validate_customizations.py +++ /dev/null @@ -1,169 +0,0 @@ -import json -import pathlib -import sys - - -def scrub(txt: str) -> str: - return txt.replace(" ", "_").replace("-", "_").lower() - - -def unscrub(txt: str) -> str: - return txt.replace("_", " ").replace("-", " ").title() - - -def get_customized_doctypes(): - apps_dir = pathlib.Path(__file__).resolve().parent.parent.parent - apps_order = pathlib.Path(__file__).resolve().parent.parent.parent.parent / "sites" / "apps.txt" - apps_order = apps_order.read_text().split("\n") - customized_doctypes = {} - for _app_dir in apps_order: - app_dir = (apps_dir / _app_dir).resolve() - if not app_dir.is_dir(): - continue - modules = (app_dir / _app_dir / "modules.txt").read_text().split("\n") - for module in modules: - if not (app_dir / _app_dir / scrub(module) / "custom").exists(): - continue - for custom_file in list((app_dir / _app_dir / scrub(module) / "custom").glob("**/*.json")): - if custom_file.stem in customized_doctypes: - customized_doctypes[custom_file.stem].append(custom_file.resolve()) - else: - customized_doctypes[custom_file.stem] = [custom_file.resolve()] - - return dict(sorted(customized_doctypes.items())) - - -def validate_module(customized_doctypes, set_module=False): - exceptions = [] - app_dir = pathlib.Path(__file__).resolve().parent.parent - this_app = app_dir.stem - modules = (app_dir / this_app / "modules.txt").read_text().split("\n") - for doctype, customize_files in customized_doctypes.items(): - for customize_file in customize_files: - if ( - not this_app == customize_file.parent.parent.parent.parent.stem - ): # Updated to accommodate local folders named same as app - continue - module = customize_file.parent.parent.stem - file_contents = json.loads(customize_file.read_text()) - if file_contents.get("custom_fields"): - for custom_field in file_contents.get("custom_fields"): - if set_module: - custom_field["module"] = unscrub(module) - continue - if not custom_field.get("module"): - exceptions.append( - f"Custom Field for {custom_field.get('dt')} in {this_app} '{custom_field.get('fieldname')}' does not have a module key" - ) - continue - elif custom_field.get("module") not in modules: - exceptions.append( - f"Custom Field for {custom_field.get('dt')} in {this_app} '{custom_field.get('fieldname')}' has module key ({custom_field.get('module')}) associated with another app" - ) - continue - if file_contents.get("property_setters"): - for ps in file_contents.get("property_setters"): - if set_module: - ps["module"] = unscrub(module) - continue - if not ps.get("module"): - exceptions.append( - f"Property Setter for {ps.get('doc_type')} in {this_app} '{ps.get('property')}' on {ps.get('field_name')} does not have a module key" - ) - continue - elif ps.get("module") not in modules: - exceptions.append( - f"Property Setter for {ps.get('doc_type')} in {this_app} '{ps.get('property')}' on {ps.get('field_name')} has module key ({ps.get('module')}) associated with another app" - ) - continue - if set_module: - with customize_file.open("w", encoding="UTF-8") as target: - json.dump(file_contents, target, sort_keys=True, indent=2) - - return exceptions - - -def validate_no_custom_perms(customized_doctypes): - exceptions = [] - this_app = pathlib.Path(__file__).resolve().parent.parent.stem - for doctype, customize_files in customized_doctypes.items(): - for customize_file in customize_files: - if ( - not this_app == customize_file.parent.parent.parent.parent.stem - ): # Updated to accommodate local folders named same as app - continue - file_contents = json.loads(customize_file.read_text()) - if file_contents.get("custom_perms"): - exceptions.append(f"Customization for {doctype} in {this_app} contains custom permissions") - return exceptions - - -def validate_duplicate_customizations(customized_doctypes): - exceptions = [] - common_fields = {} - common_property_setters = {} - app_dir = pathlib.Path(__file__).resolve().parent.parent - this_app = app_dir.stem - for doctype, customize_files in customized_doctypes.items(): - if len(customize_files) == 1: - continue - common_fields[doctype] = {} - common_property_setters[doctype] = {} - for customize_file in customize_files: - module = customize_file.parent.parent.stem - app = customize_file.parent.parent.parent.parent.stem - file_contents = json.loads(customize_file.read_text()) - if file_contents.get("custom_fields"): - fields = [cf.get("fieldname") for cf in file_contents.get("custom_fields")] - common_fields[doctype][module] = fields - if file_contents.get("property_setters"): - ps = [ps.get("name") for ps in file_contents.get("property_setters")] - common_property_setters[doctype][module] = ps - - for doctype, module_and_fields in common_fields.items(): - if this_app not in module_and_fields.keys(): - continue - this_modules_fields = module_and_fields.pop(this_app) - for module, fields in module_and_fields.items(): - for field in fields: - if field in this_modules_fields: - exceptions.append( - f"Custom Field for {unscrub(doctype)} in {this_app} '{field}' also appears in customizations for {module}" - ) - - for doctype, module_and_ps in common_property_setters.items(): - if this_app not in module_and_ps.keys(): - continue - this_modules_ps = module_and_ps.pop(this_app) - for module, ps in module_and_ps.items(): - for p in ps: - if p in this_modules_ps: - exceptions.append( - f"Property Setter for {unscrub(doctype)} in {this_app} on '{p}' also appears in customizations for {module}" - ) - - return exceptions - - -def validate_customizations(set_module): - customized_doctypes = get_customized_doctypes() - exceptions = validate_no_custom_perms(customized_doctypes) - exceptions += validate_module(customized_doctypes, set_module) - exceptions += validate_duplicate_customizations(customized_doctypes) - - return exceptions - - -if __name__ == "__main__": - exceptions = [] - set_module = False - for arg in sys.argv: - if arg == "--set-module": - set_module = True - exceptions.append(validate_customizations(set_module)) - - if exceptions: - for exception in exceptions: - [print(e) for e in exception] # TODO: colorize - - sys.exit(1) if all(exceptions) else sys.exit(0) diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml new file mode 100644 index 00000000..99870a15 --- /dev/null +++ b/.github/workflows/generate-changelog.yml @@ -0,0 +1,23 @@ +name: Generate Changelog + +on: + pull_request: + types: [opened, reopened, synchronize] + issue_comment: + types: [created] + +jobs: + generate-changelog: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + + - name: Generate Changelog + uses: agritheory/test_utils/actions/generate_changelog@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/overrides.yml b/.github/workflows/overrides.yml new file mode 100644 index 00000000..dd59d0ef --- /dev/null +++ b/.github/workflows/overrides.yml @@ -0,0 +1,20 @@ +name: Track Overrides + +on: + pull_request: + branches: + - version-14 + - version-15 + +jobs: + track_overrides: + runs-on: ubuntu-latest + name: Track Overrides + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Track Overrides + uses: agritheory/test_utils/actions/track_overrides@main + with: + app: beam diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e77ac14d..f78ba76d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,14 +11,22 @@ repos: exclude: '.*json$|.*txt$|.*csv|.*md|.*svg' - id: check-yaml - id: no-commit-to-branch - args: ['--branch', 'develop'] + args: ['--branch', 'version-15', '--branch', 'version-16'] - id: check-merge-conflict - id: check-ast - id: check-json - id: check-toml - - id: check-yaml - id: debug-statements + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + args: ["--ignore-words-list", "notin"] + exclude: 'yarn.lock|poetry.lock' + additional_dependencies: + - tomli + - repo: https://github.com/asottile/pyupgrade rev: v3.19.1 hooks: @@ -30,47 +38,41 @@ repos: hooks: - id: black - - repo: https://github.com/PyCQA/autoflake - rev: v2.3.1 - hooks: - - id: autoflake - args: [--remove-all-unused-imports, --in-place] - - - repo: https://github.com/PyCQA/isort - rev: 6.0.0 - hooks: - - id: isort - - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 + rev: 7.2.0 hooks: - id: flake8 additional_dependencies: ['flake8-bugbear'] - - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 - hooks: - - id: codespell - additional_dependencies: - - tomli - - repo: https://github.com/agritheory/test_utils - rev: v1.13.0 + rev: v1.15.4 hooks: - id: update_pre_commit_config + - id: validate_frappe_project - id: validate_copyright files: '\.(js|ts|py|md)$' args: ['--app', 'beam'] + - id: bylines + exclude: 'README.md|CHANGELOG.md' - id: clean_customized_doctypes args: ['--app', 'beam'] - id: validate_customizations + - id: patch_linters + args: ['--app', 'beam'] + - id: track_overrides + args: ['--directory', '.', '--app', 'beam', '--base-branch', 'version-15'] - - repo: local + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 hooks: - id: prettier - name: prettier - entry: npx prettier . --write --ignore-path .prettierignore - language: node + types_or: [javascript, vue, scss] + exclude: | + (?x)^( + .*node_modules.*| + beam/public/dist/.*| + beam/public/js/lib/.* + )$ ci: autoupdate_schedule: weekly diff --git a/beam/customize.py b/beam/customize.py deleted file mode 100644 index 82437f48..00000000 --- a/beam/customize.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) 2025, AgriTheory and contributors -# For license information, please see license.txt - -import json -from pathlib import Path - -import frappe - - -def load_customizations(): - customizations_directory = Path().cwd().parent / "apps" / "beam" / "beam" / "beam" / "custom" - files = list(customizations_directory.glob("**/*.json")) - for file in files: - customizations = json.loads(Path(file).read_text()) - for field in customizations.get("custom_fields"): - if field.get("module") != "BEAM": - continue - existing_field = frappe.get_value("Custom Field", field.get("name")) - custom_field = ( - frappe.get_doc("Custom Field", field.get("name")) - if existing_field - else frappe.new_doc("Custom Field") - ) - if "modified" in field: - field.pop("modified") - - {custom_field.set(key, value) for key, value in field.items()} - custom_field.flags.ignore_permissions = True - custom_field.flags.ignore_version = True - custom_field.save() - for prop in customizations.get("property_setters"): - if prop.get("module") != "BEAM": - continue - property_setter = frappe.get_doc( - { - "name": prop.get("name"), - "doctype": "Property Setter", - "doctype_or_field": prop.get("doctype_or_field"), - "doc_type": prop.get("doc_type"), - "field_name": prop.get("field_name"), - "property": prop.get("property"), - "value": prop.get("value"), - "property_type": prop.get("property_type"), - } - ) - property_setter.flags.ignore_permissions = True - property_setter.insert() diff --git a/beam/docs/form.md b/beam/docs/form.md index 52ff652d..d148b431 100644 --- a/beam/docs/form.md +++ b/beam/docs/form.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Form + + + The result of scanning a barcode in the form depends on several factors: - Is the barcode recognized? diff --git a/beam/docs/handling_unit.md b/beam/docs/handling_unit.md index 1fac37c2..a58f63cc 100644 --- a/beam/docs/handling_unit.md +++ b/beam/docs/handling_unit.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Handling Unit + + + A Handling Unit is an abstraction for tracking quantities of items that are moved or stored together. It does not replace Batch or Serial numbers, the manufacture of an Item, or the functionality of the Product Bundle, but can supplement these as a way of conveniently grabbing information that would otherwise require a lot of keystrokes to enter. By assigning a unique ID to the Handling Unit, it is possible to capture via scanner the item, net quantity, unit of measure and timestamp of the previous transaction, and then act upon that information in context, according to the [decision matrix](./matrix.md). Beam adds a new doctype, Handling Unit, to implement this functionality in ERPNext. diff --git a/beam/docs/hooks.md b/beam/docs/hooks.md index 04fb9f54..19425d01 100644 --- a/beam/docs/hooks.md +++ b/beam/docs/hooks.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Extending Beam With Custom Hooks + + + Beam can be extended by adding configurations to your application's `hooks.py`. To make scanning available on a custom doctype, add a table field for "Item Barcode" directly in the doctype or via customize form. Then add a key that is a peer with "Item" in the example below. diff --git a/beam/docs/hu_traceability_report.md b/beam/docs/hu_traceability_report.md index 2752d6e5..97f7c720 100644 --- a/beam/docs/hu_traceability_report.md +++ b/beam/docs/hu_traceability_report.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Handling Unit Traceability Report + + + The Handling Unit Traceability report provides a simple interface to track a Handling Unit over its life cycle through your company's processes. Filters for the Handling Unit ID, Delivery Note name, and Sales Invoice name allow for fine-tuning of the report's results. ![Screen shot of the Handling Unit Traceability report's filter fields, including Handling Unit, Delivery Note, and Sales Invoice](./assets/hu_trace_filters.png) diff --git a/beam/docs/index.md b/beam/docs/index.md index 89dbbc60..52a0566a 100644 --- a/beam/docs/index.md +++ b/beam/docs/index.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Beam + + + Beam is a general purpose 2D barcode scanning application for ERPNext. ## What does this application do? diff --git a/beam/docs/listview.md b/beam/docs/listview.md index b3fe3a31..9c0c2489 100644 --- a/beam/docs/listview.md +++ b/beam/docs/listview.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Listview + + + The result of scanning a barcode in the listview depends on several factors: - Is the barcode recognized? diff --git a/beam/docs/matrix.md b/beam/docs/matrix.md index 6133f078..379c1d73 100644 --- a/beam/docs/matrix.md +++ b/beam/docs/matrix.md @@ -2,6 +2,11 @@ For license information, please see license.txt--> # Listview Actions + + + | Scanned Doctype | Listview | Action | Target | |-----------------|-----------------------|--------|--------| |Handling Unit|Delivery Note|route|Delivery Note| diff --git a/beam/docs/print_server.md b/beam/docs/print_server.md index 28bf6fed..0cd4f63f 100644 --- a/beam/docs/print_server.md +++ b/beam/docs/print_server.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Print Server + + + There are several steps to get a print server connected in ERPNext. 1. First, the `pycups` dependency needs to be installed on the system, which in turn depends on the CUPS project's `libcups` library. See the following links for installation instructions: diff --git a/beam/docs/testing.md b/beam/docs/testing.md index 04d877a2..97c4dfed 100644 --- a/beam/docs/testing.md +++ b/beam/docs/testing.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Testing + + + ## Simulating a Scanner Open the browser console. This assumes a barcode of `'9968934975826708157'` which must be sent as a string. diff --git a/beam/docs/zebra_printing.md b/beam/docs/zebra_printing.md index 9334a993..ffb94c91 100644 --- a/beam/docs/zebra_printing.md +++ b/beam/docs/zebra_printing.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Zebra Printing + + + To create a Zebra print format, you need the following documents: - A ZPL Print Format made against Doctype that may contain barcodes (Item, Warehouse, Handling Units, etc.) that uses the available Jinja utility functions to generate ZPL code. - A document Print Format that uses the free Labelary API to convert the above ZPL code and generate a preview of the print output for the linked document. diff --git a/beam/hooks.py b/beam/hooks.py index 2a596cc0..23a1a839 100644 --- a/beam/hooks.py +++ b/beam/hooks.py @@ -8,6 +8,7 @@ app_description = "Barcode Scanning for ERPNext" app_email = "support@agritheory.dev" app_license = "MIT" +required_apps = ["erpnext"] # Includes in # ------------------ diff --git a/beam/install.py b/beam/install.py index 7ee4788b..12e282c7 100644 --- a/beam/install.py +++ b/beam/install.py @@ -4,11 +4,9 @@ import frappe from beam.beam.scan.config import get_scan_doctypes -from beam.customize import load_customizations def after_install(): - load_customizations() print("Setting up Handling Unit Inventory Dimension") if frappe.db.exists("Inventory Dimension", "Handling Unit"): return diff --git a/pyproject.toml b/pyproject.toml index fa9d66e9..07e29786 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,15 @@ pytest-cov = "^5.0.0" requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" +[tool.bench.frappe-dependencies] +frappe = ">=15.0.0,<16.0.0" +erpnext = ">=15.0.0,<16.0.0" + [tool.pytest.ini_options] addopts = "--cov=beam --cov-report term-missing" [tool.codespell] -skip = "CHANGELOG.md,*.js.map" +skip = "CHANGELOG.md" [tool.black] line-length = 99 @@ -42,10 +46,11 @@ ensure_newline_before_comments = true indent = "\t" [tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] version_variables = [ - "beam/__init__.py:__version__", - "pyproject.toml:version" + "beam/__init__.py:__version__" ] +build_command = "echo 1" [tool.semantic_release.branches.version] match = "version-15" \ No newline at end of file From 62d3cd96effdeaf838d321d998f23f38cf90431b Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Sat, 21 Feb 2026 21:44:00 -0500 Subject: [PATCH 13/21] Update pyproject (#325) --- .github/workflows/code-duplication.yml | 29 ++++++++++++++++++++++++++ .pre-commit-config.yaml | 4 +++- pyproject.toml | 5 +++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/code-duplication.yml diff --git a/.github/workflows/code-duplication.yml b/.github/workflows/code-duplication.yml new file mode 100644 index 00000000..d70489ee --- /dev/null +++ b/.github/workflows/code-duplication.yml @@ -0,0 +1,29 @@ +name: Code Duplication + +on: + push: + branches: ["*"] + pull_request: + branches: ["*"] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + duplication: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '18' + + - uses: agritheory/test_utils/actions/code_duplication@main + with: + max_clones: 60 + max_percentage: 5.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f78ba76d..d2bf651e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.15.4 + rev: v1.16.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project @@ -61,6 +61,8 @@ repos: args: ['--app', 'beam'] - id: track_overrides args: ['--directory', '.', '--app', 'beam', '--base-branch', 'version-15'] + - id: check_code_duplication + args: ['--max-clones', '60', '--max-percentage', '5.0'] - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 diff --git a/pyproject.toml b/pyproject.toml index 07e29786..58c06ef1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,11 @@ python = ">=3.10" python-barcode = "^0.15.1" zebra-zpl = {git = "https://github.com/mtking2/py-zebra-zpl.git"} +[tool.bench.dev-dependencies] +pytest = "~=8.3.2" +pytest-cov = "~=5.0.0" +pytest-order = "~=1.2.1" + [tool.poetry.group.dev.dependencies] pytest = "^8.3.2" pytest-order = "^1.2.1" From 6723ffc7cc2a6b6ce02e821e5f535186c46939a9 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Mon, 23 Feb 2026 18:52:57 -0500 Subject: [PATCH 14/21] chore: update precommit (#327) --- .pre-commit-config.yaml | 6 +++--- beam/docs/form.md | 2 +- beam/docs/handling_unit.md | 2 +- beam/docs/hooks.md | 2 +- beam/docs/hu_traceability_report.md | 2 +- beam/docs/index.md | 2 +- beam/docs/listview.md | 2 +- beam/docs/matrix.md | 2 +- beam/docs/print_server.md | 2 +- beam/docs/testing.md | 2 +- beam/docs/zebra_printing.md | 2 +- 11 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d2bf651e..c229cec3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ fail_fast: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace files: 'beam.*' @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.16.0 + rev: v1.18.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project @@ -57,7 +57,7 @@ repos: - id: clean_customized_doctypes args: ['--app', 'beam'] - id: validate_customizations - - id: patch_linters + - id: validate_patches args: ['--app', 'beam'] - id: track_overrides args: ['--directory', '.', '--app', 'beam', '--base-branch', 'version-15'] diff --git a/beam/docs/form.md b/beam/docs/form.md index d148b431..3c49d2ad 100644 --- a/beam/docs/form.md +++ b/beam/docs/form.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Form diff --git a/beam/docs/handling_unit.md b/beam/docs/handling_unit.md index a58f63cc..ab498d12 100644 --- a/beam/docs/handling_unit.md +++ b/beam/docs/handling_unit.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Handling Unit diff --git a/beam/docs/hooks.md b/beam/docs/hooks.md index 19425d01..537253b1 100644 --- a/beam/docs/hooks.md +++ b/beam/docs/hooks.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Extending Beam With Custom Hooks diff --git a/beam/docs/hu_traceability_report.md b/beam/docs/hu_traceability_report.md index 97f7c720..9a55467d 100644 --- a/beam/docs/hu_traceability_report.md +++ b/beam/docs/hu_traceability_report.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Handling Unit Traceability Report diff --git a/beam/docs/index.md b/beam/docs/index.md index 52a0566a..5577d66a 100644 --- a/beam/docs/index.md +++ b/beam/docs/index.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Beam diff --git a/beam/docs/listview.md b/beam/docs/listview.md index 9c0c2489..7cbb0f1b 100644 --- a/beam/docs/listview.md +++ b/beam/docs/listview.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Listview diff --git a/beam/docs/matrix.md b/beam/docs/matrix.md index 379c1d73..d29daf52 100644 --- a/beam/docs/matrix.md +++ b/beam/docs/matrix.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Listview Actions | Scanned Doctype | Listview | Action | Target | diff --git a/beam/docs/print_server.md b/beam/docs/print_server.md index 0cd4f63f..c603be88 100644 --- a/beam/docs/print_server.md +++ b/beam/docs/print_server.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Print Server diff --git a/beam/docs/testing.md b/beam/docs/testing.md index 97c4dfed..7d9aff4f 100644 --- a/beam/docs/testing.md +++ b/beam/docs/testing.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Testing diff --git a/beam/docs/zebra_printing.md b/beam/docs/zebra_printing.md index ffb94c91..6900b48b 100644 --- a/beam/docs/zebra_printing.md +++ b/beam/docs/zebra_printing.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Zebra Printing From d164646d798efd687e802fdb1e0c6bf8838bbeb1 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Tue, 24 Feb 2026 16:17:31 -0500 Subject: [PATCH 15/21] ZPL Layout helpers (#328) --- .pre-commit-config.yaml | 2 +- .../labelary_print_preview.json | 2 +- beam/beam/printing.py | 16 +- beam/beam/zpl_layout.py | 525 ++++++++++++++++++ beam/docs/zebra_printing.md | 207 ++++++- pyproject.toml | 1 + 6 files changed, 744 insertions(+), 9 deletions(-) create mode 100644 beam/beam/zpl_layout.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c229cec3..f2151178 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.18.0 + rev: v1.19.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/beam/beam/print_format/labelary_print_preview/labelary_print_preview.json b/beam/beam/print_format/labelary_print_preview/labelary_print_preview.json index 3a015b3a..fba3563e 100644 --- a/beam/beam/print_format/labelary_print_preview/labelary_print_preview.json +++ b/beam/beam/print_format/labelary_print_preview/labelary_print_preview.json @@ -10,7 +10,7 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 14, - "html": "
\n \n
\n", + "html": "
\n \n
\n", "idx": 0, "line_breaks": 0, "margin_bottom": 15.0, diff --git a/beam/beam/printing.py b/beam/beam/printing.py index 6708d041..c78ece1b 100644 --- a/beam/beam/printing.py +++ b/beam/beam/printing.py @@ -151,6 +151,18 @@ def labelary_api(doc, print_format, settings=None): e.globals.update(methods) template = e.from_string(print_format.raw_commands) output = template.render(doc=doc) - url = "http://api.labelary.com/v1/printers/8dpmm/labels/6x4/0/" + + # Extract label dimensions and DPI from settings + # dpmm: dots per millimeter (default 8 = ~203 DPI) + # width: label width in inches (default 6) + # height: label height in inches (default 4) + # index: label index for multi-label formats (default 0) + dpmm = settings.get("dpmm", 8) # 8 dpmm ≈ 203 DPI, 12 dpmm ≈ 300 DPI + width = settings.get("width", 6) + height = settings.get("height", 4) + index = settings.get("index", 0) + + url = f"http://api.labelary.com/v1/printers/{dpmm}dpmm/labels/{width}x{height}/{index}/" r = requests.post(url, files={"file": output}) - return base64.b64encode(r.content).decode("ascii") + content = r.content + return base64.b64encode(content).decode("ascii") diff --git a/beam/beam/zpl_layout.py b/beam/beam/zpl_layout.py new file mode 100644 index 00000000..3276f376 --- /dev/null +++ b/beam/beam/zpl_layout.py @@ -0,0 +1,525 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +#!/usr/bin/env python3 +""" +ZPL Label Layout Tools - Extract coordinates from PDF labels and generate ZPL templates. + +Usage: + python zpl_layout.py /path/to/label.pdf --dpi 300 --width 6 --height 4 --output ./output/ + python zpl_layout.py /path/to/label.pdf --rotate # Portrait to landscape +""" +import argparse +import json +import sys +from pathlib import Path + +import pdfplumber + + +def analyze_pdf_label( + pdf_path, target_dpi=300, label_width_inches=6, label_height_inches=4, rotate_90=True +): + """ + Extract text blocks with coordinates from PDF and convert to ZPL coordinates. + + Args: + pdf_path: Path to PDF file + target_dpi: Target printer DPI (default 300) + label_width_inches: Label width in inches (landscape) + label_height_inches: Label height in inches (landscape) + rotate_90: If True, rotate portrait PDF to landscape ZPL + + Returns: + Dictionary with text blocks, barcodes, and coordinate mappings + """ + results = { + "label_dimensions": { + "width_dots": label_width_inches * target_dpi, + "height_dots": label_height_inches * target_dpi, + "dpi": target_dpi, + }, + "text_blocks": [], + "barcode_regions": [], + "lines": [], + } + + with pdfplumber.open(pdf_path) as pdf: + page = pdf.pages[0] # First page + + # Get PDF dimensions + pdf_width = page.width + pdf_height = page.height + + print(f"PDF dimensions: {pdf_width} x {pdf_height} points") + print( + f"Target ZPL: {results['label_dimensions']['width_dots']} x {results['label_dimensions']['height_dots']} dots" + ) + print(f"Rotation: {'90° CW (portrait → landscape)' if rotate_90 else 'None'}\n") + + # Extract text with coordinates + words = page.extract_words(x_tolerance=3, y_tolerance=3, keep_blank_chars=False) + + # Group words into text blocks (by proximity) + text_blocks = [] + current_block = [] + last_y = None + y_tolerance = 15 # Points tolerance for same line + + for word in words: + x0, y0, x1, y1 = word["x0"], word["top"], word["x1"], word["bottom"] + text = word["text"] + + # Convert PDF coordinates to ZPL with optional rotation + # PDF: origin bottom-left, Y increases upward + # ZPL: origin top-left, Y increases downward + + if rotate_90: + # Rotate 90° clockwise: portrait PDF (4"x6") → landscape ZPL (6"x4") + # New X = old Y (from top) + # New Y = pdf_width - old X + pdf_y_from_top = pdf_height - y1 # Convert to top-origin + zpl_x = int((pdf_y_from_top / pdf_height) * results["label_dimensions"]["width_dots"]) + zpl_y = int(((pdf_width - x0) / pdf_width) * results["label_dimensions"]["height_dots"]) + else: + # No rotation + zpl_x = int((x0 / pdf_width) * results["label_dimensions"]["width_dots"]) + zpl_y = int(((pdf_height - y1) / pdf_height) * results["label_dimensions"]["height_dots"]) + + # Detect potential barcode patterns + is_barcode = False + if text.startswith("(") and ")" in text: + # GS1 application identifier format like (420) + is_barcode = True + elif ( + text.replace(" ", "").replace("-", "").isdigit() + and len(text.replace(" ", "").replace("-", "")) > 10 + ): + # Long numeric string - likely tracking/serial number + is_barcode = True + + block_info = { + "text": text, + "pdf_coords": {"x": x0, "y": pdf_height - y1, "x1": x1, "y1": pdf_height - y0}, + "zpl_coords": {"x": zpl_x, "y": zpl_y}, + "width": int((x1 - x0) / pdf_width * results["label_dimensions"]["width_dots"]), + "height": int((y1 - y0) / pdf_height * results["label_dimensions"]["height_dots"]), + "is_potential_barcode": is_barcode, + } + + if is_barcode: + results["barcode_regions"].append(block_info) + + text_blocks.append(block_info) + + results["text_blocks"] = text_blocks + + # Detect horizontal lines (dividers) + lines = page.lines + for line in lines: + if rotate_90: + # Rotate the line coordinates + is_horizontal = abs(line["x0"] - line["x1"]) < 2 # Vertical in PDF becomes horizontal in ZPL + if is_horizontal: + pdf_y_from_top = pdf_height - line["y0"] + zpl_y = int( + ((pdf_width - line["x0"]) / pdf_width) * results["label_dimensions"]["height_dots"] + ) + zpl_x0 = int((pdf_y_from_top / pdf_height) * results["label_dimensions"]["width_dots"]) + zpl_x1 = int( + ((pdf_height - line["y1"]) / pdf_height) * results["label_dimensions"]["width_dots"] + ) + results["lines"].append( + { + "type": "horizontal", + "zpl_coords": {"x0": min(zpl_x0, zpl_x1), "y": zpl_y, "x1": max(zpl_x0, zpl_x1)}, + "length": abs(zpl_x1 - zpl_x0), + } + ) + else: + if abs(line["y0"] - line["y1"]) < 2: # Horizontal line + zpl_y = int( + ((pdf_height - line["y0"]) / pdf_height) * results["label_dimensions"]["height_dots"] + ) + zpl_x0 = int((line["x0"] / pdf_width) * results["label_dimensions"]["width_dots"]) + zpl_x1 = int((line["x1"] / pdf_width) * results["label_dimensions"]["width_dots"]) + results["lines"].append( + { + "type": "horizontal", + "zpl_coords": {"x0": zpl_x0, "y": zpl_y, "x1": zpl_x1}, + "length": zpl_x1 - zpl_x0, + } + ) + + return results + + +def smart_group_text(text_blocks, width, height): + """ + Intelligently group text blocks into logical sections based on layout. + """ + sections = {} + + # Sort blocks by Y position (top to bottom) + sorted_blocks = sorted(text_blocks, key=lambda b: (b["zpl_coords"]["y"], b["zpl_coords"]["x"])) + + # Define regions (for 6"x4" = 1800x1200) + regions = { + "top_bar": (0, 0, width, 150), # Top header bar + "main_addresses": (0, 150, width, 500), # Address blocks + "divider_1": (0, 500, width, 550), + "shipping_info": (0, 550, width, 800), # Postal/carrier info + "divider_2": (0, 800, width, 850), + "product_details": (0, 850, width, 1050), # PO/SKU/Description + "bottom_barcodes": (0, 1050, width, height), # Bottom barcode area + } + + for region_name, (x0, y0, x1, y1) in regions.items(): + sections[region_name] = [] + for block in sorted_blocks: + bx = block["zpl_coords"]["x"] + by = block["zpl_coords"]["y"] + if x0 <= bx < x1 and y0 <= by < y1: + sections[region_name].append(block) + + return sections + + +def generate_layout_map(sections, width, height): + """ + Generate a visual ASCII layout map. + """ + # Create a grid (scaled down) + grid_width = 90 # chars + grid_height = 24 # lines + scale_x = width / grid_width + scale_y = height / grid_height + + grid = [[" " for _ in range(grid_width)] for _ in range(grid_height)] + + # Draw borders + for x in range(grid_width): + grid[0][x] = "-" + grid[grid_height - 1][x] = "-" + for y in range(grid_height): + grid[y][0] = "|" + grid[y][grid_width - 1] = "|" + + # Place text blocks + for section_name, blocks in sections.items(): + for block in blocks: + x = int(block["zpl_coords"]["x"] / scale_x) + y = int(block["zpl_coords"]["y"] / scale_y) + if 1 < x < grid_width - 1 and 1 < y < grid_height - 1: + if block["is_potential_barcode"]: + grid[y][x] = "█" + else: + grid[y][x] = "·" + + return "\n".join("".join(row) for row in grid) + + +def print_analysis(analysis, sections): + """Print human-readable analysis.""" + print("=" * 80) + print("LABEL ANALYSIS - ZPL COORDINATE MAPPING") + print("=" * 80) + print( + f"\nLabel dimensions: {analysis['label_dimensions']['width_dots']} x {analysis['label_dimensions']['height_dots']} dots @ {analysis['label_dimensions']['dpi']} DPI" + ) + + print("\n" + "-" * 80) + print("SECTIONS") + print("-" * 80) + + for section_name, blocks in sections.items(): + if blocks: + print(f"\n### {section_name.upper().replace('_', ' ')}") + for block in blocks: + print(f" [{block['zpl_coords']['x']:4d}, {block['zpl_coords']['y']:4d}] \"{block['text']}\"") + + print("\n" + "-" * 80) + print("HORIZONTAL LINES (Dividers)") + print("-" * 80) + for line in analysis["lines"]: + print( + f" Y={line['zpl_coords']['y']:4d}, X=[{line['zpl_coords']['x0']:4d} to {line['zpl_coords']['x1']:4d}], Length={line['length']} dots" + ) + + print("\n" + "-" * 80) + print("BARCODE REGIONS") + print("-" * 80) + for barcode in analysis["barcode_regions"]: + print( + f" [{barcode['zpl_coords']['x']:4d}, {barcode['zpl_coords']['y']:4d}] \"{barcode['text']}\" (size: {barcode['width']}x{barcode['height']} dots)" + ) + + +def generate_zpl_template(analysis, sections): + """Generate a production-ready ZPL template with proper structure.""" + lines = [] + + # Header + dpi = analysis["label_dimensions"]["dpi"] + width_dots = analysis["label_dimensions"]["width_dots"] + height_dots = analysis["label_dimensions"]["height_dots"] + width_inches = width_dots / dpi + height_inches = height_dots / dpi + lines.append("{# Shipping Label - " + f'{width_inches}x{height_inches}" @ {dpi} DPI #}}') + lines.append( + "{% set label = zebra_zpl_label(width=" + + str(width_dots) + + ", length=" + + str(height_dots) + + ", dpi=" + + str(dpi) + + ") -%}" + ) + lines.append("") + lines.append("^XA {# Start Format #}") + lines.append(f"^PW{width_dots} " + "{# Print Width: " + str(width_dots) + " dots #}") + lines.append(f"^LL{height_dots} " + "{# Label Length: " + str(height_dots) + " dots #}") + lines.append("") + + # Top section - may contain store number or routing info + top_blocks = sections.get("top_bar", []) + if top_blocks: + lines.append("{# === TOP BAR SECTION === #}") + for block in sorted(top_blocks, key=lambda b: b["zpl_coords"]["x"]): + x, y = block["zpl_coords"]["x"], block["zpl_coords"]["y"] + text = block["text"] + lines.append(f"^FO{x},{y}^A0N,40,40^FD{text}^FS") + lines.append("") + + # Main address section + addr_blocks = sections.get("main_addresses", []) + if addr_blocks: + lines.append("{# === ADDRESS SECTION === #}") + lines.append("{# Ship From (Left Side) #}") + lines.append("^FO50,150^A0N,35,35^FDShip From:^FS") + lines.append("^FO50,200^A0N,28,28^FB700,5,0,L,0^FD{{ doc.ship_from_name }}^FS") + lines.append("^FO50,250^A0N,28,28^FB700,5,0,L,0^FD{{ doc.ship_from_address }}^FS") + lines.append("") + lines.append("{# Ship To (Right Side) #}") + mid_x = analysis["label_dimensions"]["width_dots"] / 2 + lines.append("^FO950,150^A0N,35,35^FDShip To:^FS") + lines.append("^FO950,200^A0N,28,28^FB800,5,0,L,0^FD{{ doc.ship_to_name }}^FS") + lines.append("^FO950,250^A0N,28,28^FB800,5,0,L,0^FD{{ doc.ship_to_address }}^FS") + lines.append("") + + # Horizontal divider + lines.append("{# === DIVIDER LINE === #}") + lines.append("^FO50,500^GB1700,3,3^FS") + lines.append("") + + # Shipping info section (postal code barcode + carrier info) + ship_blocks = sections.get("shipping_info", []) + if ship_blocks: + lines.append("{# === SHIPPING INFORMATION === #}") + lines.append("{# Postal Code Barcode (Left) #}") + lines.append("^FO50,520^A0N,25,25^FD(420) Ship to Postal Code^FS") + lines.append("^FO100,560^BY3^BCN,100,Y,N^FD(420){{ doc.ship_to_zip }}^FS") + lines.append("^FO120,680^A0N,30,30^FD(420) {{ doc.ship_to_zip }}^FS") + lines.append("") + lines.append("{# Carrier Information (Right) #}") + lines.append("^FO950,520^A0N,28,28^FDCarrier: {{ doc.carrier }}^FS") + lines.append("^FO950,560^A0N,28,28^FDPRO#: {{ doc.tracking_number }}^FS") + lines.append("^FO950,600^A0N,28,28^FDB/L#: {{ doc.bill_of_lading }}^FS") + lines.append( + "^FO950,640^A0N,28,28^FDNumber of Cartons: {{ doc.carton_number }} of {{ doc.total_cartons }}^FS" + ) + lines.append("") + + # Second divider + lines.append("{# === DIVIDER LINE === #}") + lines.append("^FO50,800^GB1700,3,3^FS") + lines.append("") + + # Product details section + prod_blocks = sections.get("product_details", []) + if prod_blocks: + lines.append("{# === PRODUCT DETAILS === #}") + lines.append("{# Left Column #}") + lines.append("^FO50,820^A0N,28,28^FDPO #: {{ doc.po_number }}^FS") + lines.append("^FO50,860^A0N,28,28^FDVendor Part #: {{ doc.vendor_part_number }}^FS") + lines.append("^FO50,900^A0N,28,28^FDUPC #: {{ doc.upc }}^FS") + lines.append("^FO50,940^A0N,28,28^FDCarton Qty: {{ doc.carton_qty }}^FS") + lines.append("") + lines.append("{# Right Column #}") + lines.append("^FO950,820^A0N,28,28^FDSKU #: {{ doc.sku }}^FS") + lines.append("^FO950,860^A0N,28,28^FDSize: {{ doc.size }}^FS") + lines.append("^FO950,900^A0N,28,28^FDColor: {{ doc.color }}^FS") + lines.append("^FO950,940^A0N,28,28^FDDescription: {{ doc.description }}^FS") + lines.append("") + + # Bottom barcode section (SSCC-18) + barcode_blocks = sections.get("bottom_barcodes", []) + if barcode_blocks: + lines.append("{# === BOTTOM SSCC BARCODE === #}") + lines.append("^FO200,1050^A0N,25,25^FDSSCC^FS") + lines.append("^FO150,1090^BY3^BCN,100,Y,N^FD{{ doc.sscc_barcode }}^FS") + lines.append("") + + # End format + lines.append("^XZ {# End Format #}") + + return "\n".join(lines) + + +def process_label(pdf_path, output_dir=None, dpi=300, width=6, height=4, rotate=True): + """ + Process a PDF label and generate ZPL template. + + Args: + pdf_path: Path to PDF file + output_dir: Directory to save outputs (default: creates 'output' next to PDF) + dpi: Target printer DPI + width: Label width in inches + height: Label height in inches + rotate: Whether to rotate 90 degrees + + Returns: + Dictionary with analysis results + """ + pdf_path = Path(pdf_path) + + if not pdf_path.exists(): + raise FileNotFoundError(f"PDF not found: {pdf_path}") + + # Determine output directory + if output_dir is None: + output_dir = pdf_path.parent / "output" + else: + output_dir = Path(output_dir) + + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"\n{'='*80}") + print(f"Processing: {pdf_path.name}") + print(f"{'='*80}\n") + + # Analyze PDF + analysis = analyze_pdf_label( + str(pdf_path), + target_dpi=dpi, + label_width_inches=width, + label_height_inches=height, + rotate_90=rotate, + ) + + # Smart grouping + sections = smart_group_text( + analysis["text_blocks"], + analysis["label_dimensions"]["width_dots"], + analysis["label_dimensions"]["height_dots"], + ) + + # Print layout map + print("\nVISUAL LAYOUT MAP") + print("-" * 80) + layout_map = generate_layout_map( + sections, analysis["label_dimensions"]["width_dots"], analysis["label_dimensions"]["height_dots"] + ) + print(layout_map) + print() + + # Print analysis + print_analysis(analysis, sections) + + print("\n" + "=" * 80) + print("PRODUCTION-READY ZPL TEMPLATE") + print("=" * 80) + template = generate_zpl_template(analysis, sections) + print(template) + + # Save outputs + base_name = pdf_path.stem.lower().replace(" ", "_") + + # Save template + template_path = output_dir / f"{base_name}.zpl" + with open(template_path, "w") as f: + f.write(template) + print(f"\n✓ ZPL Template: {template_path}") + + # Save layout map + layout_path = output_dir / f"{base_name}_layout_map.txt" + with open(layout_path, "w") as f: + f.write(layout_map) + print(f"✓ Layout Map: {layout_path}") + + # Save detailed analysis + analysis_path = output_dir / f"{base_name}_analysis.json" + with open(analysis_path, "w") as f: + json.dump( + { + "label_dimensions": analysis["label_dimensions"], + "sections": { + k: [ + {"text": b["text"], "coords": b["zpl_coords"], "is_barcode": b["is_potential_barcode"]} + for b in v + ] + for k, v in sections.items() + if v + }, + "lines": analysis["lines"], + }, + f, + indent=2, + ) + print(f"✓ Analysis JSON: {analysis_path}") + + return analysis + + +def main(): + parser = argparse.ArgumentParser( + description="Extract coordinates from PDF labels and generate ZPL templates", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Basic usage (assumes portrait PDF to landscape 6x4 @ 300 DPI) + python zpl_layout.py /path/to/label.pdf + + # Specify output directory + python zpl_layout.py /path/to/label.pdf --output ./my_output/ + + # Custom dimensions (no rotation) + python zpl_layout.py /path/to/label.pdf --width 4 --height 6 --dpi 203 --no-rotate + + # Process multiple PDFs + for pdf in label_spec/*/label.pdf; do + python zpl_layout.py "$pdf" + done + """, + ) + + parser.add_argument("pdf", help="Path to PDF label file") + parser.add_argument("--output", "-o", help="Output directory (default: ./output/ next to PDF)") + parser.add_argument("--dpi", type=int, default=300, help="Target printer DPI (default: 300)") + parser.add_argument("--width", type=float, default=6, help="Label width in inches (default: 6)") + parser.add_argument("--height", type=float, default=4, help="Label height in inches (default: 4)") + parser.add_argument( + "--no-rotate", action="store_true", help="Do not rotate portrait to landscape" + ) + + args = parser.parse_args() + + try: + process_label( + args.pdf, + output_dir=args.output, + dpi=args.dpi, + width=args.width, + height=args.height, + rotate=not args.no_rotate, + ) + print(f"\n{'='*80}") + print("Processing complete!") + print(f"{'='*80}\n") + except Exception as e: + print(f"\nError: {e}\n", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/beam/docs/zebra_printing.md b/beam/docs/zebra_printing.md index 6900b48b..69559ec8 100644 --- a/beam/docs/zebra_printing.md +++ b/beam/docs/zebra_printing.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Zebra Printing @@ -140,21 +140,34 @@ Additional arguments can be passed to the function to customize the text. Please #### `labelary_api` -Generate an encoded Zebra printing label via the free Labelary API. It takes the following arguments: +Generate an encoded Zebra printing label preview via the free Labelary API. Converts ZPL code to a PNG image for preview purposes. It takes the following arguments: - `doc`: The document to be printed. Required. - `print_format`: The ZPL Print Format to be used for generating the label. Required. - `settings`: Additional settings to be passed to the Labelary API. Allows setting up the following parameters: - - `dpmm`: The desired print density, in dots per millimeter. Defaults to 8. + - `dpmm`: The desired print density, in dots per millimeter. Defaults to 8 (≈203 DPI). Use 12 for 300 DPI printers. - `width`: The desired label width, in inches. Defaults to 6. - `height`: The desired label height, in inches. Defaults to 4. - `index`: The label index (base 0). Some ZPL code will generate multiple labels, and this parameter can be used to access these different labels. Defaults to 0. -##### Example +**Important:** The `width` and `height` settings **MUST match the label dimensions used in your ZPL format**, otherwise the image will appear stretched or compressed. The `dpmm` setting should also match your printer's DPI. + +##### Example: 6x4" label at 203 DPI +```jinja + +``` + +##### Example: 4x6" label at 300 DPI ```jinja - + ``` +##### DPI Reference +| Printer Type | DPI | DPMM | +|---|---|---| +| Standard | 203 | 8 | +| High Resolution | 300 | 12 | + --- #### `get_handling_unit` @@ -187,3 +200,187 @@ Add text, barcodes, and other printable elements to a ZPL label. It takes the fo {% add_to_label(label, barcode) %} {{ label.dump_contents() }} ``` + +--- + +## ZPL Label Layout Tools + +The ZPL Layout Tools are designed to accelerate the process of creating ZPL label templates by automatically extracting text coordinates from PDF shipping label samples and generating production-ready ZPL templates with correct coordinates. + +### Overview + +Instead of manually measuring and calculating ZPL dot coordinates for every label element, you can: + +1. Run the layout analysis tool against a sample PDF label +2. Get an automatically generated ZPL template with all coordinates mapped +3. Customize as needed for your specific document fields +4. Integrate into BEAM print formats + +### Command Line Tool + +The layout analysis tool is available as a standalone command-line utility at `beam/beam/zpl_layout.py`. + +#### Usage + +```bash +# Activate the virtual environment +source /path/to/env/bin/activate +cd /path/to/beam + +# Basic usage (assumes portrait PDF, 6x4" landscape output @ 300 DPI) +python beam/beam/zpl_layout.py /path/to/label.pdf + +# Specify custom label dimensions +python beam/beam/zpl_layout.py /path/to/label.pdf --width 4 --height 6 --dpi 203 + +# Disable rotation (for already-landscape PDFs) +python beam/beam/zpl_layout.py /path/to/label.pdf --no-rotate + +# Custom output directory +python beam/beam/zpl_layout.py /path/to/label.pdf --output ./my_templates/ +``` + +#### Options + +- `pdf`: Path to the PDF file to analyze (required) +- `--output, -o`: Output directory (default: creates `output/` directory next to PDF) +- `--dpi`: Target printer DPI - 203 or 300 (default: 300) +- `--width`: Label width in inches (default: 6) +- `--height`: Label height in inches (default: 4) +- `--no-rotate`: Do not rotate portrait PDF to landscape + +### Output Files + +For each PDF processed, the tool generates three files in the output directory: + +#### 1. `{label_name}.zpl` - Production ZPL Template + +A Jinja2-compatible ZPL template with: +- All text coordinates automatically mapped +- Sections organized (addresses, shipping info, product details, barcodes) +- Variable placeholders (e.g., `{{ doc.ship_to_name }}`) ready for customization +- Comments indicating each section and coordinate values + +Example: +```jinja +{# Shipping Label - 6.0x4.0" @ 300 DPI #} +{% set label = zebra_zpl_label(width=1800.0, length=1200.0, dpi=300) -%} + +^XA {# Start Format #} +^PW1800.0 {# Print Width: 1800.0 dots #} +^LL1200.0 {# Label Length: 1200.0 dots #} + +{# === ADDRESS SECTION === #} +{# Ship From (Left Side) #} +^FO50,150^A0N,35,35^FDShip From:^FS +^FO50,200^A0N,28,28^FB700,5,0,L,0^FD{{ doc.ship_from_name }}^FS +^FO50,250^A0N,28,28^FB700,5,0,L,0^FD{{ doc.ship_from_address }}^FS + +... + +^XZ {# End Format #} +``` + +#### 2. `{label_name}_analysis.json` - Coordinate Data + +JSON file containing detailed extraction results: +- Label dimensions in dots and DPI +- Text blocks grouped by section (main_addresses, shipping_info, product_details, etc.) +- Each block includes: + - Text content + - ZPL X,Y coordinates (in dots) + - Barcode detection flag + +Use this for reference or further customization. + +#### 3. `{label_name}_layout_map.txt` - ASCII Visual Map + +ASCII art representation of the label layout showing: +- `·` for regular text blocks +- `█` for detected barcodes +- Borders indicating label dimensions + +Useful for visually verifying that coordinates were extracted correctly. + +### Integration into BEAM Print Formats + +Once you have a generated ZPL template: + +1. **Copy the template** into a new BEAM Print Format (create via Settings > Print Format) +2. **Replace variable placeholders** with actual document field references: + - `{{ doc.ship_from_name }}` → `{{ doc.supplier_name }}` (or your actual field) + - `{{ doc.po_number }}` → `{{ doc.purchase_order_number }}` + - etc. +3. **Test in Labelary viewer** at https://labelary.com/viewer.html + - Copy the ZPL code (with variables replaced by test data) + - Set label size to match your printer + - Verify layout and positioning +4. **Adjust coordinates as needed** based on actual print results + +### Key Features + +- **Automatic Barcode Detection**: Identifies GS1 Application Identifiers (e.g., `(420)`) and long numeric sequences +- **Rotation Support**: Automatically converts portrait PDFs (4"×6") to landscape (6"×4") +- **Multi-DPI Support**: Works with 203 DPI and 300 DPI printers +- **Section Grouping**: Intelligently organizes extracted text into logical regions +- **Visual Feedback**: ASCII layout map shows element positions for verification + +### Coordinate System + +The tool converts between different coordinate systems: + +| System | Origin | Y-Axis | Units | Example | +|--------|--------|--------|-------|---------| +| PDF | Bottom-left | Increases upward | Points | (x0, y0) in pdfplumber | +| ZPL | Top-left | Increases downward | Dots | ^FO{x},{y} in ZPL | + +Conversion formula: `zpl_dots = pdf_points × (target_dpi / 72)` + +### DPI/DPMM Reference + +When using the `labelary_api` helper or generating ZPL templates, ensure label dimensions match across all components: + +| DPI | DPMM | Printer Type | Example | +|-----|------|--------------|---------| +| 203 | 8 | Standard Zebra | Most common thermal printers | +| 300 | 12 | High Resolution | Better quality labels | + +**Critical:** Always pass the correct `dpmm` value to `labelary_api` to avoid image stretching. If your ZPL template is 6x4" at 300 DPI but you pass `dpmm: 8`, the preview will appear stretched horizontally. + +Example configurations: +- 6x4" label at 203 DPI: `labelary_api(doc, 'Format Name', {'width': 6, 'height': 4, 'dpmm': 8})` +- 4x6" label at 300 DPI: `labelary_api(doc, 'Format Name', {'width': 4, 'height': 6, 'dpmm': 12})` + +### Troubleshooting + +**Coordinates seem incorrect:** +- Verify the PDF orientation (portrait vs. landscape) +- Try with `--no-rotate` flag if PDF is already landscape +- Check that DPI matches your printer specification + +**Text not grouped correctly:** +- The section boundaries may need adjustment for non-standard label layouts +- Use the JSON analysis file to see exactly where text was detected +- Consider manually adjusting section coordinates in the generated template + +**Missing elements:** +- Some PDF elements (images, lines) may not be extracted +- pdfplumber extracts text only; complex graphics may need manual addition +- Review the layout map to identify missing elements + +### Example: Processing Trading Partner Labels + +The `label_spec/` folder contains sample PDFs from multiple trading partners. To generate templates for all: + +```bash +cd /path/to/beam +source /path/to/env/bin/activate + +# Pure Hockey (6x4 with rotation) +python beam/beam/zpl_layout.py label_spec/Pure\ Hockey\ -\ ASN\ label/*.pdf + +# Mindware (4x6 already landscape) +python beam/beam/zpl_layout.py "label_spec/Mindware - Oriental Trading Co - Carton label/*.pdf" --width 4 --height 6 --no-rotate +``` + +Templates are automatically saved to `label_spec/{partner}/output/` for easy access. diff --git a/pyproject.toml b/pyproject.toml index 58c06ef1..fc330f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ addopts = "--cov=beam --cov-report term-missing" [tool.codespell] skip = "CHANGELOG.md" +ignore-words-list = "fo" [tool.black] line-length = 99 From d7303d3af736e359a17342512927981cbdc9239f Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Wed, 25 Feb 2026 19:55:52 -0500 Subject: [PATCH 16/21] fix: don't try to create item barcode if it already exists on save (#330) --- beam/beam/doctype/handling_unit/handling_unit.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beam/beam/doctype/handling_unit/handling_unit.py b/beam/beam/doctype/handling_unit/handling_unit.py index b458961a..e5d10847 100644 --- a/beam/beam/doctype/handling_unit/handling_unit.py +++ b/beam/beam/doctype/handling_unit/handling_unit.py @@ -12,6 +12,8 @@ def autoname(self): self.handling_unit_name = self.name = str(uuid.uuid4().int >> 64) def validate(self): + if frappe.db.exists("Item Barcode", {"barcode": self.name, "parent": self.name}): + return barcode = frappe.new_doc("Item Barcode") barcode.parenttype = "Handling Unit" barcode.barcode_type = "Code128" From bc5e98f2f623a64cf8cb9d5e7b65c0c15035e51d Mon Sep 17 00:00:00 2001 From: AgriTheory Date: Thu, 26 Feb 2026 00:56:45 +0000 Subject: [PATCH 17/21] 15.6.2 Automatically generated by python-semantic-release --- beam/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beam/__init__.py b/beam/__init__.py index a066c624..c8fbbc77 100644 --- a/beam/__init__.py +++ b/beam/__init__.py @@ -1,4 +1,4 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt -__version__ = "15.6.1" +__version__ = "15.6.2" diff --git a/pyproject.toml b/pyproject.toml index fc330f0a..5408ed22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beam" -version = "15.6.1" +version = "15.6.2" authors = [ { name = "AgriTheory", email = "support@agritheory.dev" } ] From c9eda197eabe5d7443d75fb3172bd65e201a0769 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Sat, 28 Feb 2026 09:26:11 -0500 Subject: [PATCH 18/21] feat: improve cups connection (#332) --- .pre-commit-config.yaml | 2 +- .../beam/custom/network_printer_settings.json | 98 +++++++++++++++++++ .../overrides/network_printer_settings.py | 62 ++++++++++++ beam/docs/print_server.md | 4 +- beam/hooks.py | 6 +- .../js/network_printer_settings_custom.js | 52 ++++++++++ 6 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 beam/beam/custom/network_printer_settings.json create mode 100644 beam/beam/overrides/network_printer_settings.py create mode 100644 beam/public/js/network_printer_settings_custom.js diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2151178..5a371af5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.19.0 + rev: v1.20.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/beam/beam/custom/network_printer_settings.json b/beam/beam/custom/network_printer_settings.json new file mode 100644 index 00000000..aa230540 --- /dev/null +++ b/beam/beam/custom/network_printer_settings.json @@ -0,0 +1,98 @@ +{ + "custom_fields": [ + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "dt": "Network Printer Settings", + "fetch_if_empty": 0, + "fieldname": "printer_type", + "fieldtype": "Select", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "printer_name", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Printer Type", + "length": 0, + "module": "BEAM", + "name": "Network Printer Settings-printer_type", + "no_copy": 0, + "options": "\nGeneral Purpose\nLabel / RAW", + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "dt": "Network Printer Settings", + "fetch_if_empty": 0, + "fieldname": "printer_location", + "fieldtype": "Data", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "printer_type", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Printer Location", + "length": 0, + "module": "BEAM", + "name": "Network Printer Settings-printer_location", + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0 + } + ], + "property_setters": [ + { + "doc_type": "Network Printer Settings", + "doctype_or_field": "DocField", + "field_name": "printer_name", + "idx": 0, + "module": "BEAM", + "name": "Network Printer Settings-printer_name-fieldtype", + "property": "fieldtype", + "property_type": "Data", + "value": "Autocomplete" + } + ], + "doctype": "Network Printer Settings", + "sync_on_migrate": 1 +} diff --git a/beam/beam/overrides/network_printer_settings.py b/beam/beam/overrides/network_printer_settings.py new file mode 100644 index 00000000..0579e59e --- /dev/null +++ b/beam/beam/overrides/network_printer_settings.py @@ -0,0 +1,62 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.printing.doctype.network_printer_settings.network_printer_settings import ( + NetworkPrinterSettings, +) + + +class BEAMNetworkPrinterSettings(NetworkPrinterSettings): + @frappe.whitelist() + def get_printers_list(self, ip="127.0.0.1", port=631): + printer_list = [] + try: + import cups + except ImportError: + frappe.throw( + _( + """This feature can not be used as dependencies are missing. + Please contact your system manager to enable this by installing pycups!""" + ) + ) + return + try: + cups.setServer(self.server_ip) + cups.setPort(self.port) + conn = cups.Connection() + printers = conn.getPrinters() + for printer_id, printer in printers.items(): + make_model = printer["printer-make-and-model"] + location = printer.get("printer-location", "") + description = f"{make_model}, {location}" if location else make_model + printer_list.append( + { + "value": printer_id, + "label": printer_id, + "description": description, + "location": location, + } + ) + except RuntimeError: + frappe.throw(_("Failed to connect to server")) + except frappe.ValidationError: + frappe.throw(_("Failed to connect to server")) + return printer_list + + def validate(self): + self.push_location_to_cups() + + def push_location_to_cups(self): + if not self.printer_name: + return + try: + import cups + + cups.setServer(self.server_ip) + cups.setPort(self.port) + conn = cups.Connection() + conn.setPrinterLocation(self.printer_name, self.printer_location or "") + except Exception: + pass diff --git a/beam/docs/print_server.md b/beam/docs/print_server.md index c603be88..ad0cdb0d 100644 --- a/beam/docs/print_server.md +++ b/beam/docs/print_server.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Print Server @@ -20,6 +20,8 @@ There are several steps to get a print server connected in ERPNext. ![Screen shot of the Network Printer Settings document fields, including Name, Printer Name, Server IP, and Port.](./assets/network_printer_settings.png) +The **Printer Name** field is an autocomplete that queries the configured CUPS server and displays available printers by their CUPS identifier, with the make/model and location shown as secondary text. Selecting a printer automatically fills in the **Printer Location** field from CUPS. The location can be edited freely — saving the record pushes the updated value back to CUPS, keeping the two in sync. The **Printer Type** field (`General Purpose` or `Label / RAW`) can be used to distinguish IPP or PDF printers from ZPL/raw label printers. + --- A convenient Print Handling Unit button on relevant doctypes enables the user to print new Handling Unit labels directly from the ERPNext user interface. diff --git a/beam/hooks.py b/beam/hooks.py index 23a1a839..e369589a 100644 --- a/beam/hooks.py +++ b/beam/hooks.py @@ -32,7 +32,10 @@ # page_js = {"page" : "public/js/file.js"} # include js in doctype views -doctype_js = {"Stock Entry": "public/js/stock_entry_custom.js"} +doctype_js = { + "Network Printer Settings": "public/js/network_printer_settings_custom.js", + "Stock Entry": "public/js/stock_entry_custom.js", +} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} @@ -113,6 +116,7 @@ # --------------- # Override standard doctype classes override_doctype_class = { + "Network Printer Settings": "beam.beam.overrides.network_printer_settings.BEAMNetworkPrinterSettings", "Stock Entry": "beam.beam.overrides.stock_entry.BEAMStockEntry", "Subcontracting Receipt": "beam.beam.overrides.subcontracting_receipt.BEAMSubcontractingReceipt", } diff --git a/beam/public/js/network_printer_settings_custom.js b/beam/public/js/network_printer_settings_custom.js new file mode 100644 index 00000000..e98acf69 --- /dev/null +++ b/beam/public/js/network_printer_settings_custom.js @@ -0,0 +1,52 @@ +// Copyright (c) 2025, AgriTheory and contributors +// For license information, please see license.txt + +let printers_cache = [] +let pending_printer_name = null + +function set_location_from_cache(frm, printer_name) { + const match = printers_cache.find(p => p.value === printer_name) + frm.set_value('printer_location', match ? match.location || '' : '') +} + +frappe.ui.form.on('Network Printer Settings', { + after_save(frm) { + // Refresh cache from CUPS so subsequent printer_name changes + // reflect the just-saved location, not stale pre-load data. + printers_cache = [] + frm.trigger('connect_print_server') + }, + connect_print_server(frm) { + if (frm.doc.server_ip && frm.doc.port) { + frappe.call({ + doc: frm.doc, + method: 'get_printers_list', + args: { + ip: frm.doc.server_ip, + port: frm.doc.port, + }, + callback(data) { + printers_cache = data.message || [] + frm.fields_dict.printer_name.set_data(printers_cache) + // Resolve any pending printer_name lookup that fired before cache was ready + if (pending_printer_name) { + set_location_from_cache(frm, pending_printer_name) + pending_printer_name = null + } + }, + }) + } + }, + printer_name(frm) { + if (!frm.doc.printer_name) { + return + } + if (!printers_cache.length) { + // Cache not populated yet — queue the lookup and trigger a fetch + pending_printer_name = frm.doc.printer_name + frm.trigger('connect_print_server') + return + } + set_location_from_cache(frm, frm.doc.printer_name) + }, +}) From 70f4e975e161f7e643246b6e49ff6641ed8968db Mon Sep 17 00:00:00 2001 From: AgriTheory Date: Sat, 28 Feb 2026 14:27:00 +0000 Subject: [PATCH 19/21] 15.7.0 Automatically generated by python-semantic-release --- beam/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beam/__init__.py b/beam/__init__.py index c8fbbc77..d6da05fe 100644 --- a/beam/__init__.py +++ b/beam/__init__.py @@ -1,4 +1,4 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt -__version__ = "15.6.2" +__version__ = "15.7.0" diff --git a/pyproject.toml b/pyproject.toml index 5408ed22..a86bd506 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beam" -version = "15.6.2" +version = "15.7.0" authors = [ { name = "AgriTheory", email = "support@agritheory.dev" } ] From 2fa4968c93c445e503f142ca88b5ffd1a9427fdf Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Sat, 28 Feb 2026 09:34:04 -0500 Subject: [PATCH 20/21] feat: add barcode generation settings, update docs (#331) --- .pre-commit-config.yaml | 8 ++ beam/beam/barcodes.py | 9 +++ .../doctype/beam_settings/beam_settings.js | 72 ++++++++++++++++- .../doctype/beam_settings/beam_settings.json | 27 ++++++- .../doctype/beam_settings/beam_settings.py | 17 ++++ .../beam_settings/test_beam_settings.py | 9 --- beam/docs/assets/beam_settings.png | Bin 63198 -> 32664 bytes beam/docs/form.md | 4 +- beam/docs/handling_unit.md | 4 +- beam/docs/hooks.md | 6 +- beam/docs/hu_traceability_report.md | 2 +- beam/docs/index.md | 43 +++++++--- beam/docs/listview.md | 4 +- beam/docs/matrix.md | 2 +- beam/docs/testing.md | 2 +- beam/docs/zebra_printing.md | 8 +- beam/patches/.gitkeep | 0 beam/tests/test_barcode_auto_generate.py | 74 ++++++++++++++++++ 18 files changed, 249 insertions(+), 42 deletions(-) delete mode 100644 beam/beam/doctype/beam_settings/test_beam_settings.py create mode 100644 beam/patches/.gitkeep create mode 100644 beam/tests/test_barcode_auto_generate.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5a371af5..68f83376 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,14 @@ repos: additional_dependencies: - tomli + - repo: local + hooks: + - id: no-titled-beam + name: "Use 'BEAM' not 'Beam'" + language: pygrep + entry: '\bBeam\b' + files: '\.md$' + - repo: https://github.com/asottile/pyupgrade rev: v3.19.1 hooks: diff --git a/beam/beam/barcodes.py b/beam/beam/barcodes.py index e37ef30e..fc64b68f 100644 --- a/beam/beam/barcodes.py +++ b/beam/beam/barcodes.py @@ -28,6 +28,15 @@ def create_beam_barcode(doc, method=None): ): # TODO: refactor this to be configurable to "Products" or "sold" items that do not require handling units return + company = get_default_company() + if frappe.db.exists("BEAM Settings", {"company": company}): + settings = frappe.get_cached_doc("BEAM Settings", {"company": company}) + try: + allowed = frappe.parse_json(settings.auto_barcode_doctypes or '["Item", "Warehouse"]') + except Exception: + allowed = ["Item", "Warehouse"] + if doc.doctype not in allowed: + return if any([b for b in doc.barcodes if b.barcode_type == "Code128"]): return # move all other rows back diff --git a/beam/beam/doctype/beam_settings/beam_settings.js b/beam/beam/doctype/beam_settings/beam_settings.js index c653f4d6..281c3bf2 100644 --- a/beam/beam/doctype/beam_settings/beam_settings.js +++ b/beam/beam/doctype/beam_settings/beam_settings.js @@ -1,8 +1,72 @@ // Copyright (c) 2024, AgriTheory and contributors // For license information, please see license.txt -// frappe.ui.form.on("BEAM Settings", { -// refresh(frm) { +frappe.dom.set_style(` + .barcode-auto-generate-editor input[type="checkbox"]:not(:checked) + .label-area { + text-decoration: line-through; + color: var(--text-muted); + } +`) -// }, -// }); +frappe.ui.form.on('BEAM Settings', { + refresh(frm) { + const wrapper = $(frm.fields_dict.barcode_exclusions_html.wrapper) + wrapper.empty() + wrapper.addClass('barcode-auto-generate-editor').css({ + border: '1px solid var(--border-color)', + borderRadius: 'var(--border-radius)', + padding: 'var(--padding-md)', + }) + frm.barcode_exclusions_editor = new BEAMBarcodeAutoGenerateEditor(wrapper, frm) + }, +}) + +class BEAMBarcodeAutoGenerateEditor { + constructor(wrapper, frm) { + this.wrapper = wrapper + this.frm = frm + this.setup() + } + + get allowed() { + try { + return JSON.parse(this.frm.doc.auto_barcode_doctypes || '["Item", "Warehouse"]') + } catch { + return ['Item', 'Warehouse'] + } + } + + setup() { + this.multicheck = frappe.ui.form.make_control({ + parent: this.wrapper, + df: { + fieldname: 'auto_barcode_doctypes', + fieldtype: 'MultiCheck', + select_all: true, + columns: '15rem', + get_data: () => { + return frappe + .xcall('beam.beam.doctype.beam_settings.beam_settings.get_doctypes_with_item_barcodes') + .then(doctypes => { + const allowed = this.allowed + return doctypes.map(dt => ({ + label: __(dt), + value: dt, + checked: allowed.includes(dt), + })) + }) + }, + on_change: () => { + this.sync_json() + this.frm.dirty() + }, + }, + render_input: true, + }) + } + + sync_json() { + const checked = this.multicheck.get_checked_options() + frappe.model.set_value(this.frm.doctype, this.frm.docname, 'auto_barcode_doctypes', JSON.stringify(checked)) + } +} diff --git a/beam/beam/doctype/beam_settings/beam_settings.json b/beam/beam/doctype/beam_settings/beam_settings.json index b59c1ef0..f084e658 100644 --- a/beam/beam/doctype/beam_settings/beam_settings.json +++ b/beam/beam/doctype/beam_settings/beam_settings.json @@ -12,7 +12,11 @@ "column_break_vhpb", "qr_scale", "qr_border", - "qr_error_correct" + "qr_error_correct", + "barcode_generation_section", + "barcode_exclusions_html", + "column_break_barcode", + "auto_barcode_doctypes" ], "fields": [ { @@ -65,6 +69,27 @@ "label": "Company", "options": "Company", "unique": 1 + }, + { + "fieldname": "barcode_generation_section", + "fieldtype": "Section Break", + "label": "Barcode Generation" + }, + { + "fieldname": "barcode_exclusions_html", + "fieldtype": "HTML", + "label": "Disable Auto-Generation For" + }, + { + "fieldname": "column_break_barcode", + "fieldtype": "Column Break" + }, + { + "default": "[\"Item\", \"Warehouse\"]", + "fieldname": "auto_barcode_doctypes", + "fieldtype": "JSON", + "hidden": 1, + "label": "Auto Barcode Doctypes" } ], "links": [], diff --git a/beam/beam/doctype/beam_settings/beam_settings.py b/beam/beam/doctype/beam_settings/beam_settings.py index 23de90d8..15094670 100644 --- a/beam/beam/doctype/beam_settings/beam_settings.py +++ b/beam/beam/doctype/beam_settings/beam_settings.py @@ -15,3 +15,20 @@ def create_beam_settings(company: str) -> str: beams.company = company beams.save() return beams + + +@frappe.whitelist() +def get_doctypes_with_item_barcodes() -> list[str]: + """Return all doctypes that have a Table field with options 'Item Barcode'.""" + existing_doctypes = set(frappe.get_all("DocType", pluck="name")) + standard = frappe.get_all( + "DocField", + filters={"fieldtype": "Table", "options": "Item Barcode"}, + pluck="parent", + ) + custom = frappe.get_all( + "Custom Field", + filters={"fieldtype": "Table", "options": "Item Barcode"}, + pluck="dt", + ) + return sorted(existing_doctypes.intersection(standard + custom)) diff --git a/beam/beam/doctype/beam_settings/test_beam_settings.py b/beam/beam/doctype/beam_settings/test_beam_settings.py deleted file mode 100644 index 61700c28..00000000 --- a/beam/beam/doctype/beam_settings/test_beam_settings.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2024, AgriTheory and Contributors -# See license.txt - -# import frappe -from frappe.tests.utils import FrappeTestCase - - -class TestBEAMSettings(FrappeTestCase): - pass diff --git a/beam/docs/assets/beam_settings.png b/beam/docs/assets/beam_settings.png index 1ea936a3e86dda6fdb0c7ae5676ba9aa27de4963..f0716ed93a82daf2857c5bddcc6ec4b2d85ce460 100644 GIT binary patch literal 32664 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

N4nNc6n#9rZdrFUTpAiSQ0^w->VLC(_s{ovFHWw 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 UM89esxB&ik?=s(+W8`q=KYMsM-v9sr literal 63198 zcmeFZWmr^e8#YV~GK4TghjdB}-JPP83epWC(%m54jfA9tigbfg(p^ePcXtiVx7bf@ zeU814_s4g9@Au>3m~qz3-0NOzuKT*LJWoSal;rNClb|CYAl#RKB>fBl0mTmi0m&MC z@9q;tXBz|r^n7zEDHVAsDM}T4o0sO6rU(d+LfrReXIwjx zE@Rm_z#J!6B7j1{evunn+5QSIr&(3Wm66nt1i_UviCG@>rcZn^!cvd=S@3pL>4y*) z9uZtxEsgfzzKks0avyna?q#nA9tECotK^O94IYEM7tPD$r0%G6L(%ac`n-HR zj0Qot0kaP@(^-_xFflkAT3%|zkuzyCOSHV~XIJN}E9Rz?Bv8h&H92vJZ^?PduT4bE z-J0^y@TNk*Qd5We{@W86owWg5R-Sj_E1iKu6_5cr;^miNz zBG{s3Bq#HWkYaQpWaD66!>S(#iW*Fo0MmP&mEeqxN4F@~w z*f(;1WS4oAD{*!(Du)5uyf1A`kYgieA!th+kr64i*;xfK|C^{S_yJ0%1Q-8(wl}@= zg0(1Ln~jH%FPfRzkwo9vf&FYDDA|(vF}x-SDH3B4T>HTKECfcpFyF;2=|>>>_iu;X z^T14jOj)n<2rf_rQFPxFOX?0ud&^tLH(xblKD9JPlQ($9`IoOww=Pzg9WP zd!dTDiItDa6LAsHk^TAAy~zhV5vMY8S#Il?$FChZz9M;nX9BF{<|N?Razk_McoFwH zFqZ-r46->wlTDM66o^+5K0+PPenf6`dVVu1b1a08A#R07Hq#rc8-$nDmEE5>nqae} zaK;O6{k+&`%T{affG{bL`P)t#S#x^pOvlNR=%T!tB><rK@jr*X2x|^A4{Hw##xG&QVp>SRd|RJz_W1nqWda-1$)3mRhWXI^GD+jij{A{M6sABH55#U)|tc7agV9#myPPIc+du$^RVRDMjC$qj!*n@^!Oj?$zep2-$nz@x{z^TL{IyH)P zyr^U6OI?Q0-1VDFgx-L+eS$6SJBAxn2d|EajD^b7%LYBNg}sC^M1&d=>Z$9Q>NDK7 z+!@@*-J;JX&I|Vi_QDTN;4_OJnK|7HcCQ6!ys^9st|qU$ZwznnZw8SSkZBQ%d~LJz z=f}P=NZgai&RR8q^r6j?TyeKC($XxCqfDSsVK5QagR9VWi6^15_q9o!`M0HNBdr@*qLlU z88`tc>wu=GQ|M&e2%TAC4{U`^1R5L~9wzhv9!T0(v=G(du;|hry+2V;RIKQ z^G!yDiRh87Jfk9!f}{NOJN|^_MAbxY784BS0Gl|k_=333XMWGIJ}TtOe&Eer__6q1 zDKtU)VfLwG(Q(=_TUko2_;mb2LYauM^dd|k2F;Xhz_L4=s%1TBMB~E0l5dKqj`MP< zyvvlq9?wBqfG;tXZLZdna$E?$hQE$qk8j27^rBe%XI)rTzB^B)ptsW$NdwQh#l`qV zp-5#^Et~0#ja#DI2e-X(xgwkX@jm_3Ik^8IwiAjyN}S&%X&RsEb;!-rGvhVc)JP#d zZ{}q}J?3&2CgzK0eawxg@uSKsVv3fJCq^`l^vlh-oGV@(HLzN1W4&ccTunT+x<5Tx z8Ch0jJ9WA9q(+~$g0)~$Vw!4Z_&K@CZ25DawbgWerZ}dr%py#r&gR>bwHB6qKJXop zU60KI?t-v<(LMZKFO{9=GpfcK&#STPU>Wl%k^0?A*O zIOP?I6x!A1Vim`a-6zv@TpoKmn8SLqdNO+dI4IkAKKXc}SAWs_i_7$_>lE5*=tcZJ z1{a=Zq!_7LA6FD<`sMpYSUFgAKV2zRRSDMc-n>58YW^YkiFuGQ^P#{K;Zz^zMV;L* z!$*rpt%NwR<0@7w4^NJZ#hFu-o%EzDnC_QtuX7{Rf~9HvB<4>gg1RD3k#0rjU$0L- zk~;H|_*7gLmHlX}*!bAmpQs&GqgA)+lHRc6eAz-aD3r8kzpJ;0wYmG1&~C5!S_bYx zzE6QEs^E3D`t&sCpwlALAOlZS%~k7EVM()pkG_fTcmkfbakAZTAiRCPB&s)8AjWxn z_C52~?=ag^+#}0*$bJ7f20m1jgrR@hgwsSq!FKliU{JRtQa|&m_qv3XW|-#MhSr9D zmZ0gmm&<;`&Smv*%&-~7chQJwd~elj*LB>Z=bdYLgUCz0jq{BOK0N1>7yY+W@u8KW zXdLP->2XMmZ3_V)gjsTFM@dYC5$zCf4gR7}pD^Fu+)Jk3&veytP)-p+IO$@47(TIv z$RAX@pd~#(y_8Ff?t)qx8QEu6xtE}n$<1=uUOw&>_f_r8qFKyRtJP;5E+)sWIZANl za79SqaPM#Idm_p|qumGQA2(OkaMVy#5Hz;2Vly}>4RqUe;Al*0BVF9n}T%l`9p;GGEdD@R9LK^V-%#f8m@DrOmAXnE}@j<>llQ{$u=qee<6y z{^?AO|D4Ie&B6ZXQ~&hU@29FcnA%I(SOM2`6#dV7{qy8MfBDZ7g<*F;{ZFF!hn)ZT z6cDs1x-jhDizbTR62%Sy*71S4w6ZGj3TWBgA0i*{592>x?~Y?uLndCkAs~n&$V*G8 zx*~3;qbB3Ej|E|8QBo#r z3mQMbDExu@*MlIyrM{y6-F32}aAP1kov>0R<-faWTF@5x-`otuS5Qe@(w400o1nL#dX_$7;KDgn;|U?9`I@@EP>qRK^ca>(ZEt$ zNFD*z*~e7ta#62~k%!r9nLdr__B{`sHq#tA|4wn^#ouByQ+OT6?$jR*@Y>AQ3(Ayw zUtgR~T9jy}@>*?hAaU!kRoss~QPLu3E9hM*Lpnq5X}BsAKMwS1{GD z`Vp879FHFQSF861JVFWVsJ8(ys@gb&s=B{kXYsdp+e*F-$B!9Jr7rxhCsY-jQVac?o5sgsB&Is+;~M0?!KayZ zUAjMIQPL3h^sPLY4P91^`mgPyN+4j2)NzUf<{u-C?Ea0Y)$Sxs2YmuArWM~|`Rzw-=G^$#_I%(g_s=i}2gowfLt%28JRiOrX@G$NP zo9603)74>ko$|xl)pr(?i{UW6RnZSBlmpb3usyyEo13>S4n);W^i4;4m*;DlJC}P+ zHS+QFU*MCEnTt%WEPj@o%I~KMI^{6QzYt9E^(*9O98%d%c`sW9YxM-RxkHg=FuV7#4B?kk$DPShwua;H zT0eMsXp_FN)=bBxKpgJOfpL^T$L453k~Vww%aOdNr){{(wLEgH>vpP({ppL+#$c4A2+@rksg*_`*~&YB<|4#r5_TfnB3yUclw zSAEp3v98|um^v1h_?h^31&&b!w3av8-X|VXFAk>1h_cLc?MoNpi&yM&?@9B%*r*;9 zy=)8rvQi6)NS5Ta)AWTrAbcI)L&LuPb*osmqYS(4r_c44{*A?^86&9X^=MBh!Dk^5 zsr&J|p-ugP>`N<|!1+%Z?mcK-CO>c@N%^V)U((7!U6;PCh16)4W*4;60H59UDD$CQ zY&;qe+C6)~fXOLWQnxEug-fas-VO(8{9yf~vb0k!?86BQ9v zJ>aP{2_p)}0Ap%0_Vi`VjB`al-<)uT62?yuL@Cqz!h8u1l3utR(3>cTyt*#V#FnLF~}eohb%q%liEm3~NVBQ%EtOIbH<#ze`PJ z3;YONyi6B+C$yK~GcWi|k`%5l;lciDs*Q82_rswY8~rI@bC&mX1kPW>=jN1zb}9>Z z@UQ(s+=0a=PBFcT74tl^ow9Aczf(PB&0Nu=0)81hWz#ehifFnM!GF2ub2|kI@l=%t zdmr`lrPn?o!|ZLE_rpl5k`ZQ(r|LE#;VOK$K2zr+kw{!@E8tCeD0(_!T-}EAWa4O0 zEVF`vfC~H#WMmUJFXnUO$q>~VTpltRHu;1S&(AGNNnjzKO6Y8GlD@o3`*Ki?B#Z=y^hSSMj)9a2oi-|c( zJdZ@w2exmD081hx*SYr*FigXv{TwaxUr#rFbQaA5g28R~5+o73Sf?2$4%1b>5^lLT z*8Sbfy^ebWn8WW$kTiYpscW~6$aZO~C19A?j86cQeVjUNriRwsN#Rp(Sn!4p%l!C&RcyyptL=sm!HFM5ZvH^4! ztPk^U^Kzo>i6=tnZm;)z;=|Nl>v8}W<`EZC7ea)A9Oe`=gnycjIrz|_y==6@D1dAa zW?H_erNWAXM!4p6(SN&yKB1azuU|^kbKlrT^6|-O4H@7ow+F0+>DFw6htZdwLY5$i zPRf#n{_nwJo)Qw^GUe;&1Lp~Y>Ww-x9pCBBtZ`1cl!!37(c@3ecz`~cf;uhQ2^Ily za@NbHu(Ob>QGt}x_&MU=P8O)crmX9BEZ5RqoRRwNI?2q=l2*fOA${JraG%>X&hYMM zG~ulJ+kmIwHEaQ`c|^<~06RhT5xAqpFEoL7!T`B$DN5*&?7JIy=}E*Z5yy2EzBa)< ztYO&zw{l^O(06!#-GwhhTxI;;ubC>o(0I`Reu}AEGLUzFu!hjDjC$eAiQbf}Y5aLI z9zN8sOk{y%bXDQ;AT=uu|A5~xRNw35t)afd=I2ixd>`0o9uOLZ#=np-&4B8gXzO0he z>;VpwnHIDMm~S@RFS>QkyE@;ay4?fJZlI}_&68JUSe#~v#g^X+JaKeikH?D11P-Hq#-LN zU-j(T@fUWL9=QX4Ev*PmYD9pK6K7n@g0(Yr>*?r1ph490=fHxA87j-(D)P-N7h??K z5J2CnvstmKfo`yh#ZRf==w<`wIA|LZ#G>>;O3{qkearmMj4&t6T_}qmHdfav? zF88S)j@=W6)fZ@0TW(m|?tfO?6mCvj_>iP$f5LH2VKnbBD6(eBT8kC&hBE%k^G`~| zvHO5|tbxyZz>LLOE5u3Tg`IFoNeb%^dzfcd|*w*%O_SUZXbVLvnHyKr*bM7~DCBJz{z6pZU zp9+Xd&W?1}1)od@--S2}VuK)uM(ZY0UQT1%B@!;pq5&P&_Cylmv1ML0Wu@FIhU+BQsLF5JWn?011HAZFAN{ zcu9&5%ELf3DhsR;YpjwZf?I`40d20AOT8n5b`az)a#;h@rZ5euG|$6sYhHIwI%u}! zdtUEp$wb(fnxR1f?+4N}{4^6EQKXtaCy8GjEo$?dQ_SWQ z>U)+ky8s5|Yv?jWv_*U*Q05&GrUj@t*XuI|70JSVD)0eln2mf}Zk1|wOuqSA+*v4o zM$KvD_-LnHS&StU&VVEk`_e-t1QMY#ctn_Kz&d~fjarLbx)4V=G9o1(d0!ihu+G42 zl9|ith)($u*RgzGfq*1KMpKe~5HlUaWD4-}Uqk&KbV_U77iwFyqQW5E)7gcyyGDzi zFUKn>VAh=PO-}_=c)Kc{vb_+9u%;~4n!j1QCcT#fslAube#`q1qFLv-5m)(ePjHqn zWWIj9wDNt<`)our!K+d>d2~VMWJ|W2-Q|h%SY5Cn}B+DDi&wJa_&5w*-s#TpNj3Mj)r`@b2&fco4iCKUm7I$_gKIw z>2B#w)K+>6nT~N{@3&Hnw}-tW@DqrBhZl|x_3IXtd2?Ki0_Hdn>k$Yx!f4L)Jd~&R z=VRli&YC~!g{%V(OK4AICC~u$to-x>zL$A0OLxRBAF_0 z)T&e_G%V#}Yuy5r?B7>usgjk@rHx!2uuN{KRLjI#$cz|$8_-f#)W~?wJsCnXXHVZ; zg2(L22nbBknoT&399kI0&`i{*-4FW=awJb55@$@5428}YJz1o-uCbo62NlH9$W6{S z`*S7He8$}KDc28GDec`(E{|!WX6f+=wDhns6~8A4S0W0rvz6vvCC`-#D^wYX?epN& z{xUcPMDZigpqeKJA}LM6G}B8agXOS2F+Ue9KU3kw7TV6`6p0bJHvwXoM=ZD|bVqvg z7q*y!HKMmy4wpdKFyo!DpxbIN}uk}Aj#CE{tBbF za7o9or{VEhjE^2v#_a}877J3}TMy@16hh+MW8Q6N$Z&0WyWqY*OY17fS~c>_`ilw` zk@uD8^7SL_+72BX!E_CJng`5K(IqHh-9~PXzpp&loFS!s+Q!kKIhvONt-1b3ZDQ&A zL|M*MW}*41Sp$V)%Yhr)f2mmfU38?U4vSDLfA@G#@3~Pdq2GDy@GW#l`QB5IA>!~G zOV*oLvYyS4DI;nj3cLg@(wY*9=8T{Vul+1(Zd#9{6tj%(o_rx7aZ59OGdOYbv&38f zb|cW>##UB*QHzG0W;W)bT!$giRoPBOdKtyMPAR9sy0c7x!6w6$l{j}=IXbv;hEHyY!h;kr`7d8Ez6Ww;CX-=n>!A#dLYv#Ek*A4pbWdA;iX zdZd}DuIuzR6~sO(Ya`YoIKY>dhw&bBA@By9Q4!U> z&9D`_wwdLR)gu|175pfaNH!(cQfE_!QURx@oaxEgq9$eI0ZXTU^`W=8j`aL$T34}j z?tOoc^)YQ5CqGI!U%Jrr-hlzguf}G+frR4AH@gXng;EC-w9@O-sZJNCt!}|U6awP< zy+tUYpBt+E{YA~rkf^g&cMhT`VT=P_d#ruRoKz^e&~~ZR6$FL#NQrtW@o3+dC~~4l zETjL-Yo)hM40^MHFi#d`5LHTsEp1)?N`R0BuU36&P2#7KD~ON@%Bpe>|4-#2iem6&8Jjp;t}tgOqQrd;tu3gUw}ViwUL4_GB-K} zDJHlqfrMHqxowK`p=VuJ)Xr3m%`<3{?-Ok(N}Tk-CZ69x&WUL6K?gN$+;C(mZlc6A z9R}X8Kg%PZ$fe5)g^rvOAv`~5Eo2`YRM^Uj8FWmOxbSI%3$v9|?Vy4FP8B)=4F*`P zf4l(`!QBTKKtDJf_Zr}W!X_D)3I{QSDO2D;u$jrR2h8ZIs9R<2(rqn035pIix;)-k z3ScR(-C)m>^5@h$TWSy|7PfQKixbRkac*n!AUJT6?-h=oAhNTeL)oJU&}&=d8sMi{ zBU^+YoT9P&y~QrqJaAdjn*lO4TzIrTvY_0n_-G&-o)6w9yq8Uj>w#nPJqU?;%j?p0 zErI8NekI;$y4Eg;4)X=8q3tz9+RZF5{aL~a@$z;5iGmPz&v2ReC!FRzX3xf@r|FWm zlU|)3kq!%!L8fOjU0}>v3&3vB#3Lm}Tu@3;VUb{8}R1 zr2k`ui>u_ptE&0hzzVZV*|jb-UoC{`7i884pFRyLY_tX2>VWB=vu|MgoJOOHrpd=| zVUqIp4T!*X)bR@Z*p0Ce5jkQDm2Mc7-myfa#|Id2PC$~t+icF$c5*d+*f%k!nqejw z@dA(Wq`=PjGGg-rCS4GQ@9YnV?0QjuI+zd!Y1){`UGz=V>X*#HZZvf1&*~RxcjLV&TQHx8XaCb63m0;j3Teu zf3S2=>;`zo^r5K&^%B(=2VNSh>2GQu2je$iQfBy)0BO(Zqj!`ME{uD`epvXjF9LjZ zk;dN@8rkUMCO$kC8YI=Ma>S!Vr`f&~?~=omt&N_8MQ}GCC7uoX+{8O5UfoO=2zQ$M z**T#%JC+9KJn$4ckXf6S5{fxHN=RVchOfoL$+K+$N%SiZ1@C}}XAjomcF=##9DM`* z#k(<@O#%-~hA879h(#JtrveNHnAr$sypQfz|G?4>bNVW9;!4Gzfk;ZwLf~i7w*SIU z%#pa5lQH94r8gV9M3%zt`&0sv&W9nk-@@{&z zC*w(GwZdy%2VDfGYw8MA6BOafAgl7zE_Pa}AK@*A^cK^w6kfXf>hKez`w}sM>kJ8p zQ~A%qvh(sdd9N@u6Ds2u4n>ipPQbnsuOMfY9qq6h&QoLxg`fn$*O^GUml!+0$85}U z{b}ES?G&k|UKBoh?&)#Bh36+=C`h+|Ap?pu3+xCiPyy4E5|u@RxwvL}ntW_+Jh0U{ zDZ2w`zjHFANJp6=P-t`dH3VA=U&fl@*d=TQYdj#tzZJEd#U7-dGqH=1{3hFR`MNbS zLTk^V4{=>wGJ*Dx<6GS*swA_Y1ra#Uq~1tGN>*63tUlHAI$@9j^y!?oGtfZRp0=#T z9aU`22;6*dv?vl}!`8xnN}z`qrT?U-bBF6JJ#J98rK0T_RoZfyV4#8EqGz!DRhnkU zR^Z7$F0)>Xlf3E0>S3BGJJ> zTzw)|c^*zWOlZx)f`$y4knIv=pIqt(P;i+YMT-%zM%S+O?oYYpg%{2ej(h=t8?xmS z96!zI&WF|5AvBm0a@;b7^R-~E_pA~?ouslI)ent0aXPvZV_^p4GnhVPX$7r(A2N1T z>V8_JL`4J@?B}Ia1k?XWJCcQD@5nE{HJca71$KRpfugvVqfwiG6tc8o8&75=$($Zp z@166|*?4u3j;gTTX`-uPL)nJ=9ug6~kt-iRT`lRuzM!APcI)`^TtPL!yBK+4>%_|7 zkooGX+r2J#x-N1#7P{8dfwV(eD#_9kF1B1w&NoURBwwKIGTW%poO!Tq=IlpBMqu(N zxcNGIVtsGs8Lr>bnm@|Dcu8v8BjF#uOM$BN*HVr!6CD1%~jq( zwlg_q+plK(;Ce^IaCze92mVU7n>nr2~&6g>8r^ z(V|Q)S6xBqomY`9$*$?8H$>md&(;%Uwc_C^$sgoE^9a{<@*=eHRrp}BBk9qVI9>=v zMKwnA*_#W@1)v_vRMHRvcUQvoYi>C)B@&Tj$jp(89?-$wMywTUkxxCGXX*L4>D}0H zmXZMNLtA(lHi}5bY%<^awoo=xksQ5QhlW!F%_e=ZarwNtZ0QB24r-eOh{FbPiQ(09 zx}OhU%R<$--qxCL;68#ec94E+;DKvh6A>tp(F!TLYB*jeCxoF zuD2?Na%t2o`$z~iDh=0nqTdUXVbk(w`3KyP20{lbT)z$OBh5y~!ev}u0Wn$bv!`MQ z+qSbVjuVh8(a@%X>7D?JGtOWF*(zP|srSz`C9RjWVrrRone?FNQ6AA)pz`ALWq8UU zW#=l&-pRYo<-@i2%D-8=d>TriVPt6$57C*ShOYO}fS%8WXQx_!{frx2i!5rbHNf`4JbAIrP%$zrW4hGCy{okR2ry9M-EF8lUri;|Owy~x z2sGeaCq5ixcgz?pm;OeZ95Se5*2cVR8o(MwI0$(uw$~oMAI{!eCH0NLfxNk(enAtD z8uXkGPKo5Ue%|8|?15IIA#B`BdZ3^&KoOq6E(#Q0BJh>(#Gjm<6J$UutC{{#Z{(A3 zJPxM-)j~z1rszQ%ZsgXp*t{J{+cb5}bfTNt9^jrM_IrGQ^gfM(hYWw^Ctu-{x!>Z$ z*J6J}53;}(FGh<6JFY+HWwEd_AP9}vFG8ZTC}Of6IE64OHNv`!t|8o>%zT*uS>mlRNU4sh&CWXPw5nX z*sc%hT&8JxTbHQp8Aiifi#kYO#X74f(ZybcOsfNJ&ImR(cAx=0k(7$rB8&wT7x1{e z=)jb|7CZT@FhA#ybYKdT}NfZ}FT<@u#H%K3M^TS^O-w;3WNPdomo-KuTtG3^EWJhA9jJeV(qZ2DUcry`NiLeWtHU>E zB0q|>`Eqb$=ZROy6o<;BR-K;N-anfes&{jM!I)c^C0F$a7v`-u1s9vHE-C<-$%f#TC;Iw+{` zN#%8$96MtlTxh;@g=OQk?m`96azsTnNu2R9No5fXUR?m4Z=x3qQBpzF<6#TUdbDtq z?oyLSqE-^G0KBiXDy%I~vZ=(MMMq2_BSNLA&zXKN$4Xj8*zYH{UFsGaV0WXX4tQ!O{R@qC5?rk*qQgk7<(Q1Jd%}g_reu_+BBYb?0#7zuZe-QobzoQTpclkt&6gbg#X)QMd(%L_s9VHLBf5>@$x?cBX@m+E4QPmc zF=Ot%Gh&-Iz)P+KCQ>vRhx4oxKO*aH~DfjKqpCIAnjxG7RUPN@+f5$+Q+*~ zxJ&v^fvg8C3t#bI2hO_&@mI?#bBg)bNcbS{jnucrCA*zVg z0`^{R;ipp0HnMUIwDQl3K9p;?eh(?;lJ?AhR^#LQea7|C0vWV{k=XVPjVRhU+7NHL zr$%rY+SQa#UzhyDhGCx>wA ztsdMA6iJf_s@6+Xjwm=;3(XGAd`AU=)=1m6Rky7SSl4ak?TqNOS%^J2O`89yP8!#X z2kg`q@76NCT*nVFztd*}I#P*7Vb0=Mk1BWyIpaOjIc%I4Y2)h4^|+ald+Flo_jFU@ zc(tLQfy~wUzA+k4~ zMzBSp)_g7S>ZE;!j(Su$8Mq1ARUK%-k6YvLXOPj$t&n2^0VaMK(?TOH*E=X~{iUnV z(ZI3`n9NR?YdShD=hrMyI?Eb|(T%D$JBd#8Tt#Nfpb6;a=o1}=k@fMD#s@CVwgf}5fUY6+cUJ9TllwrHPywMzUV9U9WpkT>iG?K()FtF1 zNmc|&HRvI;^V>3FV}_*P8qyLt`kVteG9>)EiQ{)KeUVXLaymfIBrgeT9DK!u!;!*; z43i?f+rH%PYYuvou5~*+`g9)=SJ)0HA}@y5Dua{)9CsZk997S{mnKRaOyYef6h>sJ zlAWoJ7t3~J9LDHS)+zvu6G^tqvwa~VkBqYh+`-3Y6ai0*W#aJBQkIajqquL$KMCXZ z9C)D0ovk`-`!$+Po;#*D;0o|M_f)@UbD*OFm@<|A=!K)3Bp=$HDp_h2AXLCE5s z{AmtNEIj?cSN9LAphX%m{|?7jLLIQ5Lyzu};nGmOj%nziI5h;SF^xdS#A+x@qFAd9 zrMw0Gep`Q;ap%|Ve6viiF|=pf+KCW_6aWVDQ&B5`vTJDGRhD`xvUCX)T^l6C?&KR}H|BgNp%?`EUbhS;GR;tcNtfR)Gi zem^Pfz|ra;V^~}T`BUuJ`^_S|Q$5v2!GwMmhj;lul_dMx;E5>(jQ0atbAn7Vb?ZA1 zA@X_{WTI5Di_lRVlbEv&|GgA_H+qNmBb5-#XXTHXfu6$T%``UHq8qrxxo#IC2hK$Rrc!2QR1FM&FWK z`wf-nSm^?#aavT8r!T@|5tGzT6y0Lf9ASvn#;Zpd84hiFb3yyw!3^F%BADh~@H-81 z5ostxHX(ijSm}0^{t>T1I)YMP)Lj|uN?bGNfV%m$8{we`&aBbbDl3w+m5-CI* zQ+bVq>a|+cH$9b{{4xkN4LphP6mrlt#=<=xY=ua`I?7>Z_{d%wl^TFQ4YDOl`3;ZG{kNZz zL^}lZ&H1F$6vVGy#|wLEMkjGJOMAW}`iu?>$tM7&=Jo|3Hf=z+2{-8=a?m3#7{*I8 z%S9oSU2f^eG{dO&8*Wi04)jTDjdlP(>vV}HdpiK_wlTOIlGf5Ps>W6{_{HRc+<(odwcMuqn3E7Co7 zzYMH#6-A8Q6&b{>sJ;a_D~nxMR(Lyy_RwX_f(!8XHL8aahI~ z0Gr`WXFX$bJ?v)C9?TGV^11tU!LRH5XFTOM$wyf|f%AYJ*ZQQCZn-=C#R=U8z;)^H z+5%vKt^XrXYe}|g=*#N2d0l=4}d70<&f2(i8ccX$6d;Sis z{Xb#-1Eihx|GluXW)mJhUrjQo8~t02fcPSz0ibP2LfGGcwnM}}Ea}3@ZPY`{i0dw@MM|H-#W>NP%z+8utL}W z#qLQ!_jPlyy2<3q@u{F*;KT93r$+F-h-Iu_HyUp0u+~)h_b>nF0Rj+yJ5l^I>JtA9 zO%@wm9n7#kTI*x1N-Ss?Rmjq7rGHWK^vC+`S$3ge;IDDfH%WkOurKL#?sWJM&+|dn z;3ka`+4Z!t>wv1Dn?Ln^|FVhgl~8EL#Osh#%Ud99rKs z#dux~WzEm@yhdKTgWcolWWvq>=>3%baKQ6(qo1Zpk*oAfbL_^Oxx1dtUj5g)+sT}! zVUZ*}J-S@QQ)vPY(?{SVo^hQYV_FtPMik|_oBrJZ8^M}JHlmbX@tp<$liMD7u3RNU zs9TvkkW{!FZ1oV&fLX*e0v7D%P=+?SXFx;_qL z7J&i>Orbuaydk>1EEN$}0K|E$%0hh#K-To)(J&|^8;}V)cBxx^sc8W4QDgaP1%B{L zU?MXh%h7p#&TBQvZwRXr?+F56$RmxHyLAag7%#-G_FKokIIPv1wnRpYoF=CO5Y~mw z!aV>0-3ZWPo@j0{U91d>-Btn&pt*T&pzxf^2lFDj-Yh7wuG=mTciNksfqWtSD2}(5 zK_TMqIA%^^-$gZ6V>4Iibn(mqM2j^3;8M`dB(&M-uzWN37X{8@K^kQP!MDZ-gRsc7 zUetw^lfBPpS@_I|8m1Npv?z0}rmA^Ox?=n)wrN3~-w0}xqkVkUG=PkEyRc%QJSR%9 zr)ya$Y?2a00f_K)+F_862UfA$PN-jbpLc@ts39g$GGHE%#h~@qjgOM~y$J&AS^nE^ z)%ChPm9-cXA;JLT14z}6Ec=o<kRO>QWHEH<>vFAnR!~ z_Is@*0J}wCsxsRhtqUvL-3`Vf7q8gXV*kM@;};FzzsntNOBs8fX-d*A;+dkOp*07p z^sqfAxZVDK-n0YA-Wg~E(rZzUR6?!*8ihuk^G;g3L}LX%K%&?I$XEmFv}W2=lKa4+ zwaFE}`FGmeQ7@wfyOp={Q(@UDt2p07vf% zKrgs@B~hDxEWWZ_g(`V_1QGcjty*ja+m0R<#tT_6Ju;C5S3j1~=MRbH_xYxW#`HX_ z_$1~5)g-i; z7Y?=9Wy*8!%8PK`O4;pS~x+R|C>gJ+qIKNZDK0rw|>{2I|95+W3suViEAt=*Gx69WPU@UV{5Z$#{Sw9^7rybpr%M1}~4yl$BvB9f=S1 zJzvqV@ryox-S34}<8*qt=J8U>(y&dY^Jl`KXm#QaUs;rhO_bH=Wh2`88}>~n_3k*U zv5NLYjjz-AH#`;}8^%jMU7ro4y1{jg2mP&Pkh9GMNYbAsAyb{DNL4$0sy%n}vicHP zojoL{f7||h+o1zW4{M<<+id*`~V;_sg>PD%t4TdJ%)_; zCJp6xw38`-*tiwcne_IA4`_|hNU>uEg~P~94L+dYDg^qfTTSLZ=X%*!8j8yhR5w8# z&SC^`aJBA2iSK7gbfD*J0PD&!N#EN|=>))SL{AaSsl-dNH`de0MMdy+l5oE=$n-i* zAsvWzu^|HInqU&*0KKe4d`GTpV;i7%^vak6CwxK^85!L>_SQmX{6VG_9e`j^y-r8(v%kp6in+L!x}qNz^V4(E9HU%VbG+8hm?7_S z=j?+1XIec#Y;6p=Y|9az<=6+R+GbX37<;Ha?Z!O`4?Eg?3=08v>Mr^5mfq=KeO99b4Mq?}nnBME6HC(~HJuS5ZFEgI@F0gJYD4I4`*Uom*UYfk-*u}Wq?5^8cix#t; zQlGP;uyApWJ|xR&drYVepbRHWQId#IdDMRkpFHm>Xwm8^AASUM0j~WU06NPlygrDR zk)8s0)#YQCFklbNxo@%F(KIc9aK&@6P4nzcG0;162Doe_i%*|T``lhTk7=5%SV~5; zOj1AG*PeM@Fx^K>uk)m*Ha7}r(vV8+w~CAB-d_pjz5zTEw#aczIzYG;2(DX>ds65D z-__i*cnp?h-y3=_rSy_*D+3_28kbl4V+k@A6nQS_eFY*7hcSktM>u|W>LzqNB((!{ z#JeX0J%}iD18#C3)lonSl=&9|9(aTU1`r6Jo*lqSHh|N3~GMxG}&w&U}?B?_NeNqVK}T! z)NeGs$Um)xOy`)Z=`wm zbbO)|-5qLqB6h3NFPb3zJ}+E~u)^3ta?)FTk%BKsxD`wa-u@CrQ6f3{|KX-Oxv6j# z61tNmDV~*&LiQGax;4rJ1f1?D>VmfmXMy(?F{R*AzG0X$<+M_W_l9A8m?xc*m_h)P z?|Qs%&3Veo3|u7dd%MduC>&q$88YQ-`{|Db{*-1PMs+VpVrsuOQce-AS)Lc8atolh zI^K4bnsQdM{~&lD5%IXKwwziX@X={oNeOBSW-UOZm8`)!9^-Q${*C4`uJ4$gi3UWo z$S`9Jes_jIXuoUd(Q^^?7EJB%4&lM>U_~qAo7{nuhp} zybD_EbI=);86bOcLl{DOguN!)hY=Z$-{n_u{Pf*7=*EdTtR^cSF-JP>25I@%kq4dYq|(YNl45?+=1>WIE&O%|cMFPa6IDa()}aB0y@(l-Z~2Fy$8a7Wo;;<5@+ zAOLCk{#xh0kG{_1hnR)hLwmF$4qU1&dlPO4{ac{Vf1pS5Xqt`+CHf`|R2j7u$7rHY zbt{TNKlgi80)t1-SK@SA{U;+ONY~6zdTR2Jf~ST0w2j)&&|6d@KK%;I6s9NvzPk<^ z-NX=_5erT){i$Ose(0TK9jO!Dx8lr7j|~QBCXIdV17L9)Hc~Wv2NpTGUpN;y%bILx znCPKSwCLps5$sIcuYs!x@F+yei5jL z6(vxCZB}>3^Bwfa5FwYWowh1}0wrAO{K`sp^>2!DF)ut)47A1Qvpf))vWd)dgraz~ zvT@pzN|_hxo1aE6i&6-61RFVL@WQ65okzyX=fCKuK3+YZ-JBsZ8>GH++8Vg94R_}} z^yIjVxta_mD}I@4)UQ@yjk8}+(uqB$GkAD^Mlf4GjJngci@M<=xc|(glpQpK#hT%$ z@{(A2vj@AyhOA+44wy%ZD>X??j#GvC{80n_ODSyxS&0mB3mfv46wNiSoa~r<=_~Sy zNK93(sPQb9$b;BY&e?B1W3$)E{nUigj`GWscHsAP9YO!Y7JXko{B|3G3n5h)n)2-wxPAkUC4PeX>L%*}{0-0*9&ADY6S_ z;3qN=6;~B;z4)G^K7Mq3rMi%*o@H@D)X~7xB~ZWh>cqaGRlby?&hTn`Z>i^u_@qbS zc#WS^l+d-bt8r62acVj8(?6okC zd{5_fuTAOMvtR6u;Xo%TUVbUIu-}UVz9dPOz**l$GcxB)kkLdT0734`{Q}~ z6N)Np3x=E>)g4+H((|WEj$6Bwx9W5?%8b3+eCnnX-el43tsI(g*bh~42(H@^S-0$2 z%o4hin)P1stIk9fjB`+z(9vOu1ti;lm5re0dm1}ko?cd^;^5Pdeta|K?4pX}@X&`U zFWtFvF~PiG%VX9&NXc|ENzkJNjY_MyC^h4@rN?J;*ZhKt@i*ICpGj==I1?Z9>*sA3 zCz?BHYzO)v^Jxm2&Bt5ZFb zm-84zh(nQqsv46K=jz#ti1)UEJalu7)DJ(w+=tgCNQes@XIf*Y4T#js*M3LloF{~N zu6@r*otJlH;F25kou^H#^C&Cza}NLh4^yX={Szz0s3>}%5y)yVsc4je7AfF^hqznX z-FUv=oAkcrJs_a==QBlMg-V#x5=ZI=-zW#-Uyb4S6aL7Op; zyt(o4u=u1+;&=9r+v4`RmQ{s*4j4)8gVgNF9GO-63mOy3^704d)X$|8WkVR9Ua)^^v#h2}>8^G#1jUvlxtYs5kqO~G8FDnGffC`w@uLrW zUX;A2O;9j~@}HCgaSnz&K5Zw@477POz+*R)0+KIm9EM_3Wi5zL;YG{<6Ql4y!-? z;=T{NqlCK^A=s&A59iE#v4^N;+N?N;sBreYTpN2N3kfgOyjY$*ejV@g;UA)dU*(V# zj>XH`2fmmR2iUo z9B44^=OruV*2ROvfkD7bPQUCci1LyQmDK5LS@kZg73x1|rIb%l{A1T^mk?@fz_03u zOkSAY76RR{rSHBRkrY$FI}z92)|R(NZ!wu13$!p0=P(`X#Xril5?`s5a2$bS$;-axe=FEH(L2cqWP`yv_r3Z_& z2EVJE(Xu&;-i6vrx^#oQ4*w=6>1Qg}av)8$H>%nzHP>&~akv8$%p=x*B@5|bij!hm zfL01V^EDCuZ8apd{dD`g6SL5as!{SI?WNf7seQ2WeCO@&KgT+hAKq)J7cAjIqmnhw zBHj_D)@aiT2z3!?#lEZS9s}1>6lOL*yO?G&JFSrrL+d=qKN`5I#0cl183v6tPGd!a z(;>6?25&6w*9y4Ps2`CZKj1$gPb7qI7uKBURt{X!7_2k+W62nvZh0JRzD#63=T1D+ zJZ|tw7700;o^}aHPBE@%zDzo#nCu-BWIajA3TH}P zB@61hs%WPQNnqsUOZk30y8*4(ff3^wYB&}%4D7_Il4+lQ_;~}d*>Xoo?xFXBK{@^3 zz)8wa5icvyTiEWJ2ChTe29bCAL){0uW1WDDx8m=I*tv|RSOEe*nTS`FkLU7q0Vbg) zK9lS?x7OBY<$0By&x;akrE&yRhAa%I5HIJYderJK&o(gj;+pkz3o6EX9;SDvA)2lb zg4$sSgH?g}0+cjnk@{9z>E;K$olV($5pmD#9)DX0AbQzAGVjV}6t%%JF1?kQ>KK*@ zND;&CcQSX4iT6FYO>|4tfgXE^QWiqW>yVGrTKr=E5@X>9Iuje~Fk1fWUCjr#^KP*E zVc!sGGVZ~XhGa2rOXc6bi%Ggk&mfFx+_Q+jZN9z7tV61Hhx{Xz?+W@RZv&NJRrCWT7O2- z6WF1jYjHvuH^7}H{9%NGusu8U+A#0CA`ROYOy8Uph%|2_ME6k6LTiCFU6eMsnll+G z6i?q4c|ls+C~yVWy`nRpLH9|!KO0t{U!;Eif##ty2NjpXBn;hC=Cp%ag0xN0lW)l5 z@~~CL!K`7Gr_gcnd*zv&p^@noyKSv(Dj(>-u?TfPIP^J2aH3OJZXS0Lz0tF6=%bL( zn3>HAz3htJ_&LZSh)9+D3hVH&XoYeQ#Y`{fx|($xd-ISL9Z$f5Mx6+Y$`vgF=$5EG z4`bb}4wjUPgl^LaAf$G+1PtX9FIzEHIUY4?T?J_$Z16|eYll#y^b-D97|s4$X>&yN z7CR-%xN5>+P$pXBH>`90Mf*Sqp`6($`I*nBK2A}yHf7NXW?>4h{wUo^V+d1g(Sg|8 z<*mm?NxG--voAQZ_kCB4j8OrCk2(P``T5|da0^NTO00I8KAwZV7h#?#(G1r4%vc=Q z)6XB8oMSUa=Tq;pJdMVA0D3T4`p94g^->Fw)F@P>FUlM0y)th+4v-R}ekwe5QbIA< zNZi;GKx`9um2ebK-Kc5Ra>v=-uJ@t$8rRF>WFGquV|%{^#*vVGc=MV$#l&|W-+8Bn zU7sw4JBWLmklm5xMn*c2PAIUE<`p%%43sq=XWU;o^^k%Rs%Anqt$FVpKVzquP=~ca zIUAGg1dEf+bj5{FC5i4}yTl>(_n-hxCfXSVPyfaBW@5t6$|sFvs!e>IGbB-Kk&l%pD5(3JZ_1<=i?`6BvZBnBh*V3bjs@wpOX%3Az@lKVi@bj zJKUpBIqf*$Fj;(^$YT-O|0;fh4cEKLbxW0KbceMwXCoP^mi``{he?3+=Xi$z!s)Zu zOb^e=MYhOx(d3J*n$|9FrkNGzIwI22_jHvUZEHrDJA}!<+@?2^^332}L|o5yWj&6W z#qahrrAQQ0he!(-*#8Y-dYuha;~4jsWN;`&D897nRejtNCIE3m0snp%6{F7=sq`NZ zP{3g6zGP6E=OmJ26pOW(h;`zi;y_7C$3%I@>M_YAu%|FXLx4@d!bN)jqC<%sx8t|r zK124NClFtp**z?GkN}`#E=#JIA%$2(M;VbZk{lNDsDT&hVYP|f$r4wa)%9ozG|)h;dC zY&q6Dq?4Jp3r{fZe>}Sbx@(TZzQB78h{hJk6LBV=7SzisPoHK;Ur;CVSm!r5cLzP* zi!)p8=&dZzqRZFoS1-Ue-V2Aln!xOc*Wf9G$B+K{QQV0*(RQ$;*94F;&Reax^44_=gBq? z6XD=NAH{TS&}ncicD*E39?YP-ZNVh-Z$2dO2-~>$Og(h^p5Wfp4^CTlVBCa zo+^F|p~Cf+=UK}jM)$W$-FK5GOp#ZQqE^`#mRrT#EEDO@sRx)bYe1(6E)k0F8~`|| zC31W_H61h2G2(;Y;{B&Gq1h-Sw8;}5=pTy6kpr$MeX=T(G8Lk0;{6`-rp+++z)mflvtq#kp`>lLJ`6nA0D@oM5d*GrY6zFZgzbOIKAv*J)Jl_u{2u*WQxB-*TvuDUhlWxvC-H%gx4yeZuVXo!2-d=5s5M~I!uy|uWB9+I+DOKs|{13!Jo z(#fWkdwqNRo^B@<7+cmk>WV=7RxACzQqiDbG-^jgw&CBZQCMEJLP%mtk$wVJ{1X&g z<5F!^B!G;y29pNMIaUgm=Kx}aP7ZdY!-Cgri9M-+e4l3wo~26YG#2Yo_cp7(ft-$Gxq{z2B2 z+WQi>I{>;glE0YM;_$qL$sPJdh1Rm*5G{aEI;w6w&oDmk@1d*^!Dl}w4_2OqoiwJjKR zsG*{7JcBhHPu{Mo%Z>J(*Y0%tP?YQ_Q{Iqb-*E8^@w3|jTWRwMI3u5vI4p9YUaaC6Prrf$wcy~KC^Og64GN11%j zN$sENg`9uf0`}k*0G;Cxpc!i4tU6p!vNLptzF{RTV~372Q|gs?>kfW{u06|6(gSEx z;#T>voN9pcUORrWRp`%nIn;j5En**NwDJtUatk=Qm^s?Q0*+XcE_Uf4dZf$D0- z1+~69{O$`;T*94hH^k^C#Wj+gFj+2~tpGv7Cawf5TxWmJT}iYOkMc?Q-CPkbO5dT;$Q*1Tel`5!*ni#zgI5kd!=HMd?68-Z_Mpj`!|AGE<|ItMZbE5} z6>%b#rPK2xN1d^B$G?F!AnPi64d_g4VHKN%P8ZD}o<3%7uu4ZTjgu zgvG8*NEVwAuiA7aa3>eD*(_)K7lmP(c?_lNj2TSF}xg z&8v0VUe*sWkzN(K?@4-^;>!A0A3ZuziH@b~;H+DcC#!Cue&E>>t7de2`tUH1bz0E% z6c4c;gqqaep%gc3Azh)$R2JsPJ$zZLYeZu3B3s6KBLswpmO4lHmXY?v?%02+Nh4uJ zS5R)mhbLsAMZ$e`&R>;Cy(Ff4A`B|qMkyuf5du6$<9UU(J@C0@vz+Vrpw(~|?|yM>aO+8SXly_4cV2%F4@X(pK(s=O4h5m0A6kcjc^aa2GVWut(LB+|T z2yMSJgA^+Us+S9ZJ6Mh*$JOF#-#H0rf;QBtR73|zq2SMXUBZM{!l0f(<)8IJgYW*i z*B;vO8xmn=Vcc={XLQ)V_9pZrj{2o8l8A!t+d-bRvvd+Kln%C<``v5V?7V1pk7TNx z`jAJwGX~TURo;CHBUTIhS@;vi{k@<`LMz2i^ogeHw&Au3nLFZ`{nD=m#m%58`;IV+ zH#?L7N|bu84*Irj9i(u)_gI&;)yVs(es=oExH=ug;~}N-2dKqym~+WVm9^a~G=b6s z5;jZ&$pvYPiMK%VtH49PR9)t&k4HE(a`!oFcD`ZB#PTa~YM5S@2&QA5i~8$6bxIii z^=RsMb9AkHfO55VW|PEZGb3HK_oEmIh`1!l*uuQzNb*8>>=Tr-UmGna)2j%}{_-3X zJJ_d~;L}%Z+UE8fc2bADh9w>GtRBA>Zn~QT8c1Ey5v9XXy65!lZZS@@d%Rofum^#q zw__hP+GH=Tcoj^aW)wBPs4VAz& zfSG_21`so=3Pkuvb){w2->e)D1-^u$d)k-d7F`O4;YXed z^u)qm#i0H&83pD}$Z@R;=5;MTNq<4wj_3Uyfz-dD5b`D)iev0=qv*#>GHLi7bVc=g z<=mli$vjKR1P>EqGrDPNnhs=bl+}H8=CGY2X`Jpa$puk@S7*A4oZ{KHb)LvOe-^-Fi)Q>v=9d%e?dO$#hHNF2o3C^QasceifMK~ zawTOhSq^<#s6ckE0(=uHuFZv@?hYgi&jxsL3 zfNIC_nmgx&{7yt@c=i03&rgRP`x_$4$9OlBEAwRG=eH4ubEaEWEZRmrI_xFe*xB40 zVJ)Z*&x5fviga=Es#MDQSjzv}-v+*Z?T7}L%YDq#&wJB?HS^piolEq@G=Z&wz%{;s z^jpAYn_J`)lxXv6P-vibG0-Ct>L9*FcB`if=CMI7Sqtjvwr!J7<1~hX=m6FFmDvwK z1=R^mGbu4maNM15;Z_7f$1P~#+(d7c?vn}3?7on_$8)>OAymTISErQO zME)%`qdi9`>v1(F_8wB(*Gi4!yC9=Cr$8xKZDDVlp*OehP4smEg!7N|FQ) ztCW6n=QE#TqjBp@-J7CB8CIHOsy7<)b|e^MHAst|R98Zxlu9p7?vDF7GVd^LuYt1g z4E)B6T#EZVb*qQDLxJ`7E3%0XO=2gb?UdG7&~uWyVObZu$^EBm0g`s+c&g0hrDBPU zTFoiw!8(ap7xEt&hG;o$tAD;K&N#i8F4EY0s#f&4-?~!eTisOZi>%3!Aei`MSMRi* zv|B)gEsmr)w;`x;=yG2tDHDO=E|?HjDngsA>5-X?5aX+_PKs;bL328E-r z`W*a=wLuMhFHN-<5)aumsLpH$oZ8s+if#b|rfndKZc?Wng>l2r5-2UxoB9>sqNj+W zUcc8HbHpF!sfx%nm*6u1n%aKqqr$s~Yr~p&?3aVOzBomG)2%x)85ID~rI{jS#wHo3 zCIuo%la`?5!^Wo;?LC0TL;NG>9>o{^B+!3d7)`~eCDy19`*_8X4AIqj){I!{BmD>04 zcclC7O86C$M-4hCg37G+mgB$a}CEWi%G+JWYM zmJqgz(k4!F4;_lg_f)@5Bt*rZNbMJ7%BcBMsm|XIlMgBNFU;P;FO**(i=|uSmD!DJ zw;>M&vK~PD-`eN|n>23cTVUKfYK?%Sn^WdaF14vzrg>ZHURgsZN^#hFhu~vYfRq8hgt>Dx@e`v>*P(H;F^Ft648uv z7l++i-dC$aw??=T#Nq}pr+6TOIZ+5S=4o6y#}P|l&xX$}qN#WH&3jJbm$`cdE)XZ2 z^@i@Bl_AuoQGJs2u?sD`E`_Eu-p!Up&ndo~Vf%FFuf$9Dt{!elo&ofQBzeaSq;}C9 zMg>%+xMVAUBbD#%>De-M^X5a?a`kD-yVw(?8G+TqGu*U7jhIF9$*eDwNyaG9y-9ji|W6 zTpsS{I6DgT7`y1QIIT(=)?%%n7|qRc+e1#>i2SISe0;8 z{Bfyr$A2ABi~$mS!H_&DlA8bmDFriHo^9E^9bf5P ze9#~VEYJ3}ZwWIxXSc%-&f@2OI-a509`JTUp(fC8GVq>UyAvX89LK0>R&-^wGvKqC z({t&S+WjQjp!Cz7`=(g5LO0)xd?F7K6}p{!d4Wtkjg|B4CMV$_uj~5A_chR#U^0QI8g$H^rp&4T9ZY8< zxUg!XIUT``$5IFGliYyg`i_Q9PX+6py*jmNy(gC~Pt_#buAEn*^+gm%Y%qckzO?V0;KRRHAy)&cw z@2cSth&N}d(Uu$<7CG}O3T0b(@cQcX%y{4!_Mj#AkL{@vz|+ZAx2VZ<7^dD6U?Lya zGPxL>t&p0DNThxF)kN|Gp}^D+t2?g~z5iz;UVdzTiHC)PxtP5$h1{2Tv?R|2`6+ZNg>waP^kY}=ih zhlwu>Z;=W-&wNE-hDHA0g*&5zh3~BFY$F%`6(tSR@{ps543*5yDuLVfeXeF3lbW08 z)#7UQTu!#bp)zvz%7%P}iTn1t%&e)Bl82I_(yPsx^6mcFdMhuA{_iFefj!W!&(C)- z{&=1L^Fh8J$tQMhBH!?R0xuQZ?S4xS+g2S;MphR_2?ApNFDnDdiD{TF>Dc@}L=1A1 zs}r*C)mPR>G)|zeic-p1;ErmywOxt*Uv-uILh& z-IFKurwxG*^>Vo>WYrJ|MVi#>+-9IPF$_YsUGE}YFw94q6Xtf{g1FZ-AB~{=kJE+{@iN%h1$RMlhg?&1fL0+;}iefT=n&^XV1{8`gkP$pgCo>H!xoSm2)@4!&q! zrYDEYC8ajH(tv-EW33h**0_T^OyLkSe6CqKj!_nzB@<9=kpaRPuN1Omx>HeG3aUl_ zzLM7oAIYoPTySvy`ltW2tFHuLYgh)}|M$%Oyb^8dyeG0%I?hlhvb3W6zyQkt5Y z@a(#}IyUm_y{S$y`u#xi>awyI6;)N)qj}0J^Nz*owI0_xy8BPO-ulev9BKmi;el5k zZGV5i3Fu)mS^fFVr0WyKGXYL^c11_mr~h(-f4te1XRp<;h#xg$-%_3y07wB}yF5zs z-M@O-|N2NXKCidl^Gm&PC+}`nm6vB=Q;9=9a?ZW5r@nS!hYxA_AQq;kz346NishA+ z%UIRpkKorX1j zc=fmI;(wgI*X2XnCy;QzUzCt=7KcrhnC6Qkw&b{LZx{@eGk(bS_O_gKEWOlTE{&BB zjSkMW&wr-+73MSlK{_xtHg*QTI(4UW4cy7sJkM9Tui8HI0|Nu)c56d?nXy>*`vMQI z{Xuds4-9YRe3eM00<{=_OtKX5Fha$j4DtTgE*0g&oila_xUzx$;05MAI!U?q$L^pX zs$?hVqF1Rh@ABh}qR+Lv@f1W`(rujV*FJwR7Kk^#nLW7H+CS5Rcmo?Ya_y>Vg+aXGTgrW{LBCD| zft9G6!nF_c6#@b)?4IT8%;*0e^pE5B|G781(!8^}DhU)3?Fa?joD!IIf7lY6b6#tb zcT<4vw7gISzP}bUW(*^dL}ZFkTUU4R2K4dDMAOL2fhn@034lH4V>!j9a%UN4r>|D# zWS!u4j^=ZOr*X|6_7b>*(qhksq;J%M9?9XMOzDl|!^>*G0Lw%QtI8&Em7F|Cy_?xw zPvj9bMz8kSfe-#!$oYaX;oqmK`3xL5b{;Uk>LoIb{KIu{n5pmp!HFTDbCOy)=dw}m zaJ^8fa6$bum?KLF;EgCnyh zKb(4bn2jrr>>);Ky$*x+#}sh2&K(p00D}2qkw&mPRUXW^EeFaI$D4WXIF;Insqy_R zFx8lvXk=R(h8TE6H|%NA`28)$xr~6BWV_Qw6Rb@Y*yYXv;cT`(j;r7$}0M*U>datS^Qz*3yDQ?$B^N zn!@{IHOC;_s?vklY2NFZ_qaHf_ll(_V%lV>Cnn4xd%R)WeoZ0uY=lR5mlp;f;2ZB0 zI4(I%C%d3UT{xm~TB6gA9{?gRGpXFi?ZjY2oZf@)K``5|b&TudS`I5qy9d+P z+;jKwz(uwsb#dA$IPlAWuiO~BWK>DLA!h~uQ3t+;m5|F~q{Lg4wl~j+V(LrgXF#_) zV#b@&`!{H)p>f}jPVI~NF^ZO_Yp&5+YS=rZ8$c49XSQ&fbKX!l)WiX%XucR?^4JSG zuLXmpmv#?1o8Ynnj_){jzyP*2bnR2&qjGqK%Qj0)`{3^{agB$W&vHFaz<}^WAX~Kb zk}ua^U(|4mcWy8IyzxweJ{JF`wX(9Z3%pl5W$t3zF*`>id?Pe>1!6#@Vu3rQ1j23a z@Lbp_@i+mswLEwm>X#PpUU{yHkmbt7)44AsRDxB^#MKx{^d~~%7N+L*W2+XQZzX8D zHzr>VUrB8ptYm+S`RVrikTc8B^Gsi@!Rk#W;pvviuOg55sg-{DHzf6~);r^*Y4T$K zz;cks{B8(v`uiE6J3g0KRJ_hFr`f%PD)+5r%~CA!fH*-NxCl;ombAgqJFJ1aGoq&M zxbL_QzmXsrEo&ki>+r{Po4>tgZt1EHZFIt6z#|0%TI-S0KK)x%A20)pPl3!j)E4YN zZ0CDnnEnSSzsXR>;S!T4xANea$5kTi{Jn({_7=(e{)EwuDdY$^+(#|ef?!6cbM^p&wt}9lz+_$U$0rPMHw87tMU+K zKadbS8^m@0n4W6szO&FCg1=HEnhHC6gYiuwlpwMY@CLQ&@*cIIlGwezmwJZHA1=5T zB)`}XI~%KVVQ9qXPfW*<(2&+p*vj$WeiQEoglk8(U9@&qW_K4}VbybOIqg4#Zn!?6 zzUCPTz-1bfZ3m=^vVc4;RX_x`aN`$j2I90R&Rp#cpNXjh@m=DZcD`9)q_C#E{Oku6 zqL3c3I~Es#ALeuJh3_@&xLYO%&#BZis&TXhlC}?)Rq^DmSXfvL@Y^p`4CdTjuykrA z8tN3h+!GHtGz$@EEKyOXCp-aCMSNm@7NN#CZ|aOSKo)Z6IL%MwB2ekkjw=kYziga` zec_!)g%=y5!;RM(GGFB~Yh6_GRjWM97*|cXsfnTpooMH zHwJ0g)q2*I5Gj4pOpA*jmd>4bFR{(Z(tdCX|1|E$;k5VA^Ysu=35bTE^|ni;RjTM25PNweGsNKB$aE%paL8n^-xZoTj`BL?)7}BZ+M!kOXUWxh zrB$Fu?VvKJIJS^gd30dqL-#N+G3Z&q#LA6XE#K&`{ax zT*rOy1)lWreX^_5&O>mM3M2!O6!=ineoRq@3VD%(y7q|y@4o{?uYOhJ-E@|xRM1m! zXKH$S82HVxVSPvUCW66RzkSHgb%I}w0hMG95}>#M-@Sv5mx1Yd+6s417aPo7IR{;? z2_mC_jj}j~HA-FsAc;m2v7?0CqLK-XL&Pl5>mLHstQBUHTu;TBV8(-3=EPZY$y0gk zVim%ijm0HC^FSL-km)Y|H-uy3L9&}H5TJ4kPU#qYzMxp-q6oEj5&73C2AG@WO`%=2 z2#7goXxp9{qIfQM0vHB!Z|zfa70cc{;svujaYxHWv2Pk47VrbU=@1yAqcS_QVLy<> zUDIiKL1`q0(qc`aeRVcI%rR{?ypfPgHo$k#$L6>_bC_~cPt&lWxzm#v#{``F)#b^` zT<74B9vC7$+Ulz1d0dv7xub+v9Z~{TpHm+`@4hyaxdyrmpySw3`kB_IN;2!l8=lwY z{XeP5AzISdTMsC8LYrTx5sTLOYdTD3qUIj=-^S$oq)sQw1y2~Srv?pK&J}gGu&JDI z_ikyes5JMMoHyw%Tb|4DMmDZKm6ertT@(Qs3iA9FMxJkkcIRAol8fpDUN#^De9p$b zFgJc{ZEH`KT=dd*N++bY*%a_#2gmDVy4W!_5q$fSbLC+1Tx&?tAd5!$vc`Af)O2}k zlu>LIqH9ha{iM=IW(4X4UsABul+0e#gO$qrM`Qcu9;?OK=+HeOIU^*o>h1ZF#M z52U@P2W}u)MSp~3J5*;R5}0#2xZ$quTc+BHY1%gCuZhtc_<_(HH%fT!nlUyumGGhQ zxE1K!J(`j5L}~d&rZ`qHj{D6XW&2u$${m!QHq6qdIPtWwHCpY#4=J=ZLlPLq%wPR{;g2B3B4 z&H*G2T{n*a@4%Fa0d*c5SK|24rSd>V=3T`1!+C%o-j}ctI0}rz}8=i?7Zk>tK zLqOO(o(b3$`Qy5SO24WX!c8Y!(sIbT)RaV1e}5?=RO-Btjc0u-7O~KnYX;O1TmDEL z;0G%!_g1P)AT>xq9o&{Zpvw4pr=rPw9(iFPN69#cBj<*00kI}{gWxrx8rG`%ORSkH z*#|uMOj=1m%x7(e(+OBWZ_x()RjcM3mO7ml?~$3Aq-mLvN^j#+KaZ&iT7&TQIb*|; z52LxnHse(_s~d0A^mx?R+w{!E=H$zlt3jdMM(hZPNJjf(5RHvLoszre=r^l@qyPRe z?%`2v?oQA%r?OYJGv&VqZI>A?w%`m^n+vaQ*c=NL*Qc4;Xpy2 zP0X+Iul2h>9b~gDFqG73<7jR@=DRIhTU#a|yKw&2uv#%QJ6k~L6b%ovqx$zj5d*cT zkSNj{2lqS3oq)pq8c@X99OPa6Br*h~Z*n-TA_6)8{aYw0U^CBW#1s3lOK4}np^ic3 zNT9umg?8H%zQ45gEr7HKeGAE(Hvw6K&+l5zCx0&VzY&g?*AsFGsib^s#dV(NK>|9s8oL$C-W0UqXc$fkk*?>`Lw=S&E^ZOj}wscT-yjsKs1@LY2?(v)!pk=D_l z!2TUz@%Hxi=JQn~;{V#F;%8`J(#sQePR?u~qmv1wP_h9uzL+YzMmpP@^%UF+(1XzC zs)7Or2JJ>ZkPzmtNY?%P+;KR10gaz5pro0}qTjLj(aHA2>gu*McscXVq7^#;t{4KT z58-A-(0}dX&llQ!b_bbTg9#sTdH&;`84W1N;^;WH#2qA9TJD}>yBGPd--pg^z4;-2Cw@fO<jKaC6Zkyd8RKcD)4pQw2rKkzEr z?bi>#jhR4ZCz6Lm_i#_IZrr$Wb@hP`&G1jf!mU}fdUO2?!QFrjtpoa~XHO|VzcPv1 zmkt_|Ny|ukOYuBYHJDcPc7n-JjxXDH?dafFY47}HB7YGUuGOAgx{noATy1RDLAf_( zT#p-!QV%?|m>hQ+Kr?^Qk(Q~p+p(i(>eJ;}!B=D*K)yl4+`eK|X`^+l(=wWK)=r@Y zs4e-MSpf|w1|Vanw}#XI>16+3w)9eGAC11G?cow=cwjr6w)GguwkYUlqiurGgg3t#xgJ?q)WNPe-nWp(A;)=dp2H#Lo z&ghv9eddAa;%C6bukB0btrAIkHUCIS<=epo$u*%ZI-M1uf!zOoU(2TZX)jP6RL=qt zb8+Ia%dLVtS!P%>c~9Gbpxbv6(GBU&;D0+IbCeM||#QhkG7$(^NkdbY9(UiSOD5lhC(58lw$< zvkLfMGx+n1ukyEM87P&soR>-geg9}})-1O9wb!%9DB3_*XbD7>8Fhv-Q`5f0^P-vOD|I zM__C1=5zr&kP!$H%dC{}qMSWtUhbj zU!k}9$+;{me)$s}!%56jUOw}lN9Aj_XZE|Z-`;Gn+8%vOjf5ADn8Jx8{&}OzXn>fZ zjh{2x8H&>4e&5>?C>q;b!dEbBJUFI|)Av6G@ELTKw!`Palh7ff3EsNhHX4DF{Nf+? zk~^c}1IT3F2IwEg94)gfT}#U_WwcH9dl~)XZAo zbTGo>T{z!<*8rp2x`uRIdv_Lle`vaCT+Y|sVha6fp?S)x> z#*yc<9yyUI`r662%(BE@g45_o&;%Ok2*l91{T__SC~BE(%eg* z6?y=xR}z?f&_Spl8M?-#?0v z)Br5om)xGB+~{ocJ$-l zg*h#Rjh2`JYg@vOa1uRu8gh79)pW77A0+Rl*X5d}DW;Z&WXwNB#5eZY8z(1}h0({xZZs#7<$+{%RuoisOsvuZkid*9)?j+^uYH4xta!QF*CQU$dhL z;NM=kkYaM$xFavcQSR;%Aid5rTJa@l2Je0S#g=8!fPF)lMk3j1Giq)y%tiI!I7Itg zZ&adM>li>d9wT_z0H%cH9wgZ&*~YrV45DTI-6PC+Z`xllO}u+&bP?*bT=uz`|K>SB z>{mZHUq}@8$}Goug4X6Ym0-WHi@G)B;WGw{wY^7?kwN>VFnDM_2u7X?d3f(|P&H9@ zvB#M*b$-(K_~}E+w$8$N@>JYeR=hHLp&Ld82D>?TS43@mcU^Sp*)v+4D1)|!Z9_f6 zT{EDwR?{U(fAvo|vg9>=l2?M|%n0=>&kqe@i{y7fX&>T^z(S!BHIW-T0`;rndvB59 z%0QbcTJVcEV^&n26d60F{H!e+xzAw-DJS>Wc#*-1GQTcP?bG`gKkyr0Hv;&|ZjPo% zUOo7*wf~9UY`QX5QgDCk1K#|5T?reOz?UuoS>SEoSN$usRR@6IS>^_ei3ts{9!r44 zw0omd4l|Z}bKU!E(H3eY=5{aucdh*F; zqtcKqC(f@U!lu)9rk-q2X?iZRcyr}-bNPOTShAms;uU_i0SK+9E)Gt$w(ND(0sN zhV5oar-KLD>!QT*<%85FOn;)9LMQ;}_T!;$S-@f#4hmwK$n}slynz!9Jhqc1Lnc>M zdkdQxLB#*l-j|0%y|@3LoGB(1nWI%A;be)DEg@P^mco#oipegrjV(h-6cWN%3So%K zzKpH1X5X@tW$c7Ww*2l-&vVYHljpjA*Y*9k|5|4G%zM4>*K*$-^> z%yoS?N}xPg;!dA7W48~R7<&)YdBW;xBtO6CcW*&K>D@IRXCc*B&d`l}A+@|_zGtA9 z-G@b$Dffg+iV4{t^%m_fMNL)-LA@rQFC$tEj;VMo2PPK$-i`Wz9>m{?b~fte^;9K& zjfiuRKsPdxIEiVej_Hxn`RH_^{Pd6kLVq8K(L>HGGcbr=rMBf1 z*}o62wO`!Q>)7X3d*32_fgXvx4S5fWFZld zy+x0wNwoQvQUp>>sqXV$A(uk|MXk-%OEk~Yrl<4;jBLJo#THZ-?JeS>tHV(#((BHP zH4*_a^F+#HQSz8lR0TE4yCi&8phB{9PhDP~06I#Ic?}4d=aiA>Dlz&ypg8s;p09W! zAd16LGp@L`m&+^*5e)+Esx(u}e#C{uI!f#JTy#k+eT$6E!?=S+HjZtvhnyPHpOk*^ zkeL;uEk$2y-g*d|wKu?R@>&XOdR|PwDD!LT4GsIYn(?k_8v(aF^O#Sou95>cNYwGg zN^``~K%x|HzPmvd(3<%a;Ngl@cv8Y+d1O4ufe8_x zCo;>DS7F1MDrDJ9sz$A3CQ80A+l@pFPazg*HzldIqf0Sxc!G0V}O!XZKjen3>!%;3X6K zC#^|&6Z3nT73)Ipk7deLW~ur(ix;`Rx|9>0v}hFHH4=7EOJKdswIz;Kxap#{WZ14< z=;xSa+)%zU*1C=gNSnHoiIb(nQ3w)Kc5uUwDy}9zl*^=Wf&{hC0K7Y$V7=e89cCso_z7uchTjZa-zy7J+XNu-sB-7ZC_vcio!is%Tw!p5Bw8Jn+VDtp3u(>}QXr!5|jmFvwY*eW73e`R@J&UQlcH4$c#{g*JF zd4&Iox;9m$k?+;7%ZsLtw{s7vyzE^-8WN{0CjH)y8n3-AP$AJIPrX5?!yi7ZvL|1V zVcOSs z+%aDGcz(IEMSS7x9f8zkk@f()u5AYMVs5r}dX$Sn`0Iqi%#kGIPUq`jNuGpoUp(8p z673ghV0gHtmhy^qN#d|a*a^ACA`2)B-HOYW8p#}#7q%_ZCCQQI=e1JhXhw8#dzT6Z zFB&X2xsp8~fM#&iHi!>%dYHbnH#KKC^!q#9>(7RI!7GfX?rFa5pUGfzB6{_!Cp}lq zVm0-+M4N$@Qe)Qf_<&(d*qXirH{L#!Ezx~(G-5VO+#RU*TcigWr=%O*Avx;NO>l)2 zDxHRiHxg2;dp-S|sHb^Zlu4F#+eN{UHYTc5xlr_FtXW$?K~6BK#QP=rJdXk9SsTX- zF1)aTN~`gj-DiCnb*@V_*B$mI-%z|1LTVV1zvCd#mnx*n*xh}ero}s=$J4=)Ei8NA z(%qL+$cFwcLJ--~PKj=^*+>H)2JziJ?HAz4H|xtBOOh+AQulQT(Dk!_=Ojz^j_GNa zSu`K7sLkdcLG2@MT!$b$Bbmv<0@x+vViA_$DOhw#+>0PZ0odI~g zobQvTJ?ftBcS`L=)C+EGzkD%wDiLCC7I-YtF+XjebLcLnd|^4#nkv6mz;tyd-q|s? zJ>`%v85>#pp$l%PCH!x=yEU1pBsrQ=Zt0~e>YF7(zoHGbnet68>>!>i=Pu_ zen}nOLBRCGdXJ!DG!i?HabSbrP|cOHtV!+@L4!^sLNQv>sd(nZwKI|*quf;9;j$$< zAr2DF31$-rb~J8!?R^K$I}v1w44E0L8T-~&pS@oKhs#DF>8l(PDH zUytQwRp+R!la1%U4h}-D$q$z#ww^jl<-BZX(DaqVW9C+X>y}6Zmf?BCpSn$pKV&rd ze*K4A%}OcvC-HYO6HVR4g;i4`mxZmq7xBInL(Q$L9U0MX2+EE;(v%Om07=PTj@_Tl zNv8g8JHI~=)aML8?8+^`S*Fj5khL+{Z5(drBL{TIu9-Fh6ZW*FJ`0_Y&Nv%GkBmH>tbF+p+w_~@+GJRAcGs4Of0oZ` z8XkE6oyozW+BAbR&TR#^%r}IZ4;Sjf*SP+y10p6cqN$Bn8`OPT+Tr^oz;Wa zB3)9u*{jzf-kJ5tE}GQ;>LIGmRH?z_Fr%X2m4E@+uH_|>oZ2&GD3W2hz;DF_e(g{XyF@V zD2;tf4g0;;F4{oy@F{~2v&9)gKVVmeL=+RvV#F#wHL`v^(YR&3PpHjcvtw}#TdvOA z!E@}uepIlD$yoGM$4WW(g!nA^#eJ>enBap>Tv(TTukN}yTS~*?XUYcuGj4wV!2*xc zpAkWWW*UPIw57?X6*;di$|sm-kR0Q!8=ERlY%-8kwd|RE5HXzmUWxOn-UIFQu0kby z6WPh>t;6s;yhoEQ(o$k0yWX#<&$I=b4&Q`NSAWU%#1-gHeF*mw*{8P7Mel_vUOE)p zadCNFUwAp@mQq&ZLP+8Z~WsAAsZ;W}$IUhZ2;ml&RBzD+7#t)vLVyz#C~E=zL} zT3l{QX@8~jJq6);4%l1$mXE#Ghae-X0QlfWc*$W&(~6ULH@_3wxnYZ=a*V{6j**tx zW6pG@;5PtNG`<$^-OO7P9L&cTc z@szG@H!`j+qbN9*+TL7_&7~dsT$w2;g7XgyxW;R~KIcErBdewtQS_dA)k~czta_$o>5_Ry72j(llrq+E;f{Q_mh7oyKKpk$t1`wTbz?JIQo)*%86V+hW>* zDbHbX|1#j5f6{;qxqS2j?e`Ea*X`3~{rHDZH+Mv>uA6Lqn0faKqhxwGLZIE;N~i}F zfi2M7+r)FA{_|vD1=Z{1OVUAZ!%3?EK^W@x&m45h^$jMuS~J&3PmnPF7KyQ7R@Z}pT~{^TQd+yXZ|p0KX@<6F#iN)N?(3}eK?0{$*&{siOyVDjZ?eGe zV-S7a_vzx7@K-$!T_I-}Br`McT=wRlg_H~Ks>?YBOb@Cs+mXU2UWvhiUQXP7b1nEO z-oQ4}cR|(JAOJv$I@+4XUjL52-Qc`5FCk;i_3rrmBLRpu<=01CrG%JN zxl2U9XB-6gZk}7u3(rWZJ9d8Ac>c;m>2-lFmLp<9bPGU$o>GP&O||l^1$9sIJK44l zC2l&;lz7k>Iuy3@GnT|+zp$~t%e#Ypc28sJd!-};!L;t$rv~|q9#td1tK!bx{FOd1qD2- z2rbgYTd4+`cUu1oGQkCQ`YqdF+s3WtO2_X5mg#I zWwzIUa3?C@45KR`0~#_4Ju9Np15!|dLavY||4l>wqRHB`cg!ft8m78vV7qk^O%hA< zo!$CZu&6dkg^W5d=W8mJhpz0sh9;x~fW)&cB_}MF*Uf#xy0|IegcKTL1lGJ{y0i); zcP3Th_lNH}x=A~OCc|Z}XnGbO&z9@#&UBwU$@=q6<-jm>u`M6<;npR4yX1&&w~}=6vt=0 z`RZ|^G7enxLO(3%aMbc%nhFKRQ{ic$rQ*9FOGcH}iqMEt!McmX28lKqH%J<(JE}Co z*mD1-I2+1l33bKNWy#aU#BA7nkH%|9_FQBnwhvQAX51GGBwF*eLVuH-0mn7P5kGI@ zEZduDBLA|Dr8bmSE1IVb)H0;hypf33p&PsPZe7M5PeJ*kDUq0g2Y;Y&wpk`rQ=Wuo zTQ=sZWL0IAZuPTVw^Pql`tCzja1B^=aw7PS^OdMR^{?e3>$WvZNxV|?uIXlNdbi^EHb7loiAoN>rM5z>d`Dh@hp$ZqJ$y7>zRLXh&i&JzL4P z)-h*2lBeBv(G?BK#lTF#AFz+q;)A}B}m{to0tdpkmj}? z4sv+&;D;Y@it8A8qdG)ecA(8E%j?EI;dKv@nu(9OBPXXZdk}pX%tg3*s+Cb7=5fif zrz1Bwq<4ZVx<(?!4X)s+OwlX4GB#W@7bBVVLGOQvg92RewcStm;AL5tVS}ALUT=~`1!2_%m?GHWU&KQLxqW( zDE|$EiNdxYhUo0BB(HvV?-Fq z3yzSiB~xd7vxr6-gp9sgQm1!o$9XA<{nmvyAv;cE6XWMq zmR?&R)hcw#ws2rZhiCkQh}XjI59&5k!8Xs>dLrflv>FHGy$cZj(Z zO_&Ip;9eoF9jN(K!N=~zGuu3OH8sX}d97kl#LYL7-w3Z{D9$=_?FS9tldTY%B-?~? z_a56y*j$R4A!W*;wp=odXLI_E0av6~myS|hiV4~}rx^R8=a5O!Zml0h#gd>!lhopy z%(%^Vg|VA*@%;!|al2>L9#rBBIqj#qP=R|G>I%$cVvZ?vx8y!Q!1-OYw1#0sE!MY% z`h268fg}FznDM%xC(CwD+l799pFsy{aC49|D zB)ks(s3ruBNze#^|u2q#L?*y-m+nfx9{L2c2E)!Iok&__Dx{qBC&!GjU? zT$k|_(5}i?f-#S)9>TggMn|dM!11{)w*=wjtl(IvyL{a$6sw_?@J(C5^nKwE@3=8l(+SXu=M31j@Jx^K`O+TO^s)vO>Mr+o z+>!VmZTQc?yZvz^`_2`!Yq(nx|9p&0B>p&$eqSWn6Q7qg07 zj)ug|#~EtinrQ7X(+)McYUNe(jPc1MnOR`pYGBqR-#R`JZu>JltMU6;{_g+^_R=6M zB6xiU18Wf(g)yjZ&xAUk2EhZ!L3WHl`(<@RYRaDWO`+h^w9$v?(;Sg11f{@T3oD5B zctF6i9B9nSIGR}U7_h#_64+8ipzy_P=Z4Y{$TRbGWcl)j9&xsofQe{-qT|cGG;#Eb z9c*GJpfcNvE9VlTRnW4^8$KVFv5`dLGHSYTzV0{}x!KJV&W!<_Eruv_miYw}y!!h; zo9zs$?swgj8fq?jEorR$Uzk4Fw%ICv#;(J z1E$j6&*|r48G;d~(|rf#B38G$L9>Q^Q^V2R4ZGng^w9F5_o8l}uCd3({q%JTM;dw< za;4Lr9sRP9W+Jj*}$16+uFj-c@7asN>eqVS_{ zQ{IO+V`L@nMf2@CdKHX3yPfbk-LCVs#-M}Lt9O$B4p08?0TD|sd35opmWGa{Fy>7b zcZSJ9N%)xIy$nVP}oUC^$UXfn$Pk<8G|F_2<8b_uFqB1%=;q z!qvUsos9pyjw=WtJ*Fr{AK9OW2ns9c!{h1mg#Pp>-}c(Cul+~e^lOm+-YdVB)IS&U z|9Jzcic?$EY7ia`sj74wyd`P4E=i)48@|Kl&Gs6pl}R;-5&|3qUhmp)e=mbB+o}Y) zw1(l(u;kPFQh)@kOH1`0MYH=~Z{9#miK63+z0LNe*ajduEksVUJMiXE7-WBD!_d{9 z)tR$?1CcuvKXNPUarMpp#HUC)@F%*oi`X%Ihv-`f=sj{TgdLzRaos?E*nGAJVXA7(!nDs70^5Bohe8xKa5y} za16j}sDvWvbL$`?GfIW9A&R0uAJP68tguO`;I#wJZ|Q;-q!JSKN-YxN0f=@Z%LRdh z5RmTp$28Dec<@8#EW@li#0-`v{n1ZbO8lNXKSQnAKPUCK{Z)cOhHn-kBTbM>lI4}F z5Dg?&Q!lGw#{SKAQU86q_Qh4)QlETZgL~CpKtDdf8p64`RGefAz8-gKaPROMxL=<1 zdKeiJBivV^wXK4z! zbktPu@14C{$Lc=BaqtLOwna%5h(VduAj-g0Hv=vZPV9@liBS&PRXDR}0SI`=Y4Gqm z3;1=S1pd6+HG}FwTL>Wt^<14}kzFpfBGdImF`_#KO+tL<2$;V5UxQ>hz;s39Cxc-N zjhYmld;jkT!SlB;1!7NKOAgOrn?XbnVxy9!-@A-Fc*AKCjW2OVEKb=b)Yx zXvG%sDd-#vBd_i|ZfgVr9YJmfVRwuD%_6HqK*5N^-FPf*%<{5$x%7HBmU# z+|_1tIzranQF}c_Z)5tVeOC`nE)piafcI%B{C7E)J=fvAvRg`f*lqCcBiwUus5H@Z zGP8(O&laF9Z2J^MDLUWOrwIaX$*bT6;sr&gM>SWZg(~Y(rCexO;SguSjvN=gAq0x3 zK{%O53gyT`#ZH)%y+rt-;Ug-}7liY)=k3Wnc-V0VL!sFpc@?C|d}B|EZdEH*8MPv{ zVH%4rWcQ+wX@glr7c!2EG!&w6kg*3pzw`-#1ShV!E4zK-u1;IP{r*>-paV}~frFv- z=yPWI&qqF^PeAlMPI9!spN6)Ubs+;b6#ygUJWdC$cX)U4mvebQr_^Yhzra!jmDjt7 zE3ZD&)vd5Nd@#Z?4D0gBu{vL;yr`HsSwpoSPfiR)sJfwjmBOJ4te})xv@PAd`!Pry z=38BgrtQ;tLHKM;S(D2biiQJ-^C?<%qax(J0?tc6#j`WbfkDVSzA3v#<&G_T#9}> z|G#Hj7@|-5ov6jFo|wn$TRH+xx1qdcKu9y~mSUjU$7);2KCg7IeQjAkxR$e_ zPHJ%^YJqnM4Wu z15tV_IqO-!@BkfJ(^T_2hc^X(d+6%Q^R z&Nmk-7oxNJtRe-jxf+BMwgtt^`<(st&UJPqO6ZlVzU=ideuupfAG1$tyK7+4`v9l6 z9HevK^wiGEFZ;}|lNsD+y*St2{!m>JN%qE}8qRJ(I^}yyAVEw@1thw)HMqLDQMNx- z3`jC0NhvvZh(kj|(m5naRZtmH=LWe(7B*%G z%96jvA8jp1Tym6zS~gL4XJXZIG(feQn2v-;qRi3gi)xKdkcx*aoKBOQr`Cg}0Ac&b zKQ0zhB(Y*^py-bOCjA7(^$n~-#HHiGlg2D|?~h6HKAcz_D>_im=5%V(w$3)1X;yON zSU|FaD06ahb)9@tP$oxcThm&oxTbxyHwea!fjXL=2v0mT#H$ADZ@lxvfYmK_vUOL?#{DIxi%_1 zpp(uf7UQLUI#F&Gia$`svzFes1iwp|lcsyV%L(^IR)^0J6a_L@h3Yp?o)P_qkg@Umi&SR02?Owg^ z0krc>p~%xYI8;_2X>37N{0 z0aT*fX<3SQrE{M;p8$yCqZXCrqb?oWG>%w9*8Gc>ilUfZx`D4kFQ4Bp{u$Vw9BuxP zC~EyuMe|WgKT@d^r#a_tI&sNoHqT^YLWnmZ|HR6pZ>S6+R+qm-6hYO-+7tpsIddf~ zBh>@i%{>PASimvm^A>A-b0q1T%H~ihUXn|ek!XI@k{$XGy}{>HLnjig11`Hx!L1+w z%)bRCuX~Ic)z0fLqNSU*LQL_Wu0I<(yl)APTD_+ckqO=EtL0P~2K=SP8s0^9PTb)epc_MQ)J~ar z{7@(w*{J85|yws(fRT@;{X!*GVYJT|Zj@KA&`mgy^(_j@tW z7WibAh(RqKlKF{g#PXcN=uzo6<1yR2Sd{Zk9=9vNk?~a91fPNIP>pY9iG1Ja!lr4d zUhl66jlsPN7nhBcsC~?Er)Wi>z_q5{LoybYy3)mhGB&ck%k-XzLL58ci!aDiQW*uS zdNPT%u1~MW@<_x)zRJIHT16{>2K?B>!b-qztGC&*_Ci0ON=d}&jHx{{Y+`O&a`Y#f zKeM+Is5iEhB3=Bd&KmU$BGKE@hYBeTer2{^SGUD{^wjajDxsKy1HsQ1BR^EV_j5Ku>6TR-RWE&)JRUD-jz70300a~X+?Px zI&VP{gcofGrvhN~d?(NEFNSqY3jIwhU4m}feTwiQp)5CT+R|zUf+!sItD~I5A)@CY$xuIF7&vyWkd^K z4?)={zg&Zm)8J0DJ+C)AZ_m16E}K()bV7J;S{lhB`}7`A1Y99rN}U?raX}8RCq$>b z8Q2nR#jC1OW|Tmxo+F+9j0!1p!YzrgR4bM&hk8>~wWujNn9azs9gWV>KTEobbq7OL`0nH-VJZNRjFt`&}8_|R}YgVP_=WL(Vp}9Rg9=a=%mf@a8&Vj$Y6DS zohV?#Jg&<%247W==8rz(V-=z6H4f9OMnuCen%CrL7Ej(*;haE9+?a&~5&H8#3iCd{ zfX@u7D%>Ht(9o!n5`FO7jc}LV&WD~LJLajO}>HEz77oW;|&b1eE4f_0F@`%Stl09QzxQ_|G3alaD~O&Pi8i+_=pPqyJp?rc6aFZE0=H zWa5k|SM>y|=|rF)&$V1OG3&0vTUKXq9mp=@7(XHUXUnCyR5iSk0Mkil_w(ranWgI~agxxCLyWv5vVk5wG-9e z{uW6ao3ag!%D?^W7Dw8vHx+&(UZ_K*?R~Sk)cGhzCm}yyoGCluV&i%8YWIBm{ksm+NsTy zN;jP{(D7IAFDFM2S>L2B?Pxq%ShqIK)6KU6{3P~_EAxK+yiePND-B@ zI?EJyg|V$b7UZ54wfg>4Ya@b_ghM^>JP<5bf4eZUsb9B&cOa`pl@l9-_Cnxzv%WdI zHSZ9lnhkXhtaemm=x32CDtwiZ*uMnb&QnNw^8^b~$;&y=&)SSB4@}4fuApa3uA?H! z)*W1r%uULJ{Aqtp##8zZ^h_wkwly4*?rL0RdA_Va7YEu#B64rmAml?-@F{5sw-iZX zZ5+uCwR!mNK~~MCF7bAyv=Bx0CuVD|GJ(h1AjFGXSgu>Sp8C3)htu+qs%LcdSf-}f z`s(~eJU1@>qsUWhy{G#8h3f&JCslCTW?EQaExk#C2!ma}NhM-Jcd(6Bnq;knL{Q7r!{)pIub zk+%iO1hsQo0jPWS_O_v%U*4Z}gzMduT%D9N2x5^3)JMg-PANp&AgkkYDaX5C`ZT=oYsyf3xuzg`}5E#?;!@&pC&D+8Cm{(Y8qD4mL^8y z&6scV-@nM#`q~9tF0#d~A9oO%HHFEKFj5Ijxva?_Tqkt;w6VR<87#|fWM;u|RQMW} zPY8Hj(#DyImL~By8y;eF{R!rlTF^8S?PnlVB^jSyX6OSmZB$rd#5|I3i$g$F1B}iG zt=^HkZ)sajEEp!JVC=JOrFl9%Kr~L~sdv73&a`LV`5CITqZswUbRtM#JAwc{s{TN% z@s1R)N6QMqUagz3t&P`SrIr2y+SQXaLWuEqt|d__%S>r!qJCVAj7_fqwj1$?)*z+J zNGDI$AchW-kL@%r{?3Iq)9h%4+~U$L`_*GIxFyFF)^naof3H>jcJ}ZhOM?v*Z=$)7d{>ofPUM{+SjP4g@H%LbhmFM>??5R^(wuYiGq^Y-XG)lH9 zEx4_Hda2V8_1=XNqi#cqkvU-SrEK>FuHEnD>-!N@(vyLc-Hp%!>5Mei-dS64D1Yp$ z>{ZV`Ll%88Ai+qrXS6jJ_L;^7)vLl3hWDAPmguKv`2Pdae#^z5tU{Et9z9qq(v`Y( z`^wE~O)&G>LZ8URYt7jThD}@J&BXclHw}tl5~$;2#O$5+yn%`zKbIU+{3>QTz_RCG z3H|9v4txV_ij|J+e8nN!?vv^1p!adzC6t$S=$uC4L%3 zcTelRIA>^!j^ADe)801L6)9C8p2{DS4tQg#T4~JZ?Q#d~)$4t`xx((n{e)@p&MHW{8DyA<%@P5O8^pw;Yj0!4B0k4k)PvR9`gN}`vnSEenl<|4qC9wKgD`ROyip}kU&=&#%}Z~p$v z{vMepbTD(iHoGQ&|Mwhx3;&m)@a;55n$m{lYYh)U5lZedt(OcNe)MP@OkzN;$i|g= z5%MpZ4p%Dm??d6+XT=7{j!y5#X6qdJVP;D!%HYDk1=XeaZ*rLD%)F7v-u!2&;B5n%f@h)&?Y#PhM8 z4_h~$TEh!lk=MiFRfCkTK$uv>A*`gJOZ&f8N<$9Y7kC3NQj9SiT;7OFpS$c;C+&s= zjG_V*VJdATLi7sy5eT+py6RRs6%VXiKT-_Mlxm5mZkL;2!SkV_ggzf9w(D z#UKSaf`~RZ=Qmy84FOKt?83mwD;vK7g}gz$L@UE21k&+DkdDM5$RlRp$<)TX?fK)= zP=p-%*I>y6y8;-L5Mvmg3!5Z1{sKNL&mvm*0(Dg4aj`Rg_wJYeJBa(wF@5DajUU`t zjV=6#LUnDcNawuWOZ_HUTsSs*?B}^9hv*4|t^jR>b!6an8_oVvJkBEZ*XsEG;P+EE z41yo{`sB7cH%YHJ!;N=xF^%6Ty1IcD%R{yvP|C9vWvhMe?3<03-WPhlP&P52AoS2T z6$Y%=@g$`D!Xpn<*`UWG%b&~KK!~2q|MCN&|0OpHB8^MAtf2egg|;i{Gy@Avb%U_f zpSgGb8!`UxLFs?l@YR-5YEfOdP~ujv8Q`;y1B-@#rL)n%K&d5KPxMZdC{*NvwYY@) zJlTQ$lW3T0b+A#l-fiQz+~5|30L`JmlK+4FKQE*gyvC9n{_nOVIM+|B5PN>2a*&Ze ztpj+B&XI|YpK*-!K3E4|^^_(tkTztYG7voDGM1wrbnYcOU$mlH2f1v!x=bj) zm=p4vb;F;tMIEy5SnfKj4G*Z;7jO@~cuf6y7~o$7W8s-LN#1mb+;A)`VHhnFQa2Dw z!NS`F&%`k5o6+_C5d6KcZ~U6t|IXyEsr?5^{k3cV-iE(+?LX(nuLJq7E%NI?{`&@D z`*qj;6$|{jYySyPJb#5ze{GRp5%S-+>8}X+uN&mF;jiT9@3Hum{QMnc{mN_qx=lCH l{K{+p!pi?|kj1L>_Dk)qlS|4kp&s#X3g?v1rpX!-{vU+FC7l2O diff --git a/beam/docs/form.md b/beam/docs/form.md index 3c49d2ad..bb19afd0 100644 --- a/beam/docs/form.md +++ b/beam/docs/form.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Form

@@ -19,7 +19,7 @@ For example, when an Item is scanned while viewing a Delivery Note record, it wi |-----------------|-----------------------|--------|--------| |Item|Delivery Note|add_or_increment|item_code| -Beam uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned. +BEAM uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned. Custom actions and client side functions can be added by using [hooks](./hooks.md). diff --git a/beam/docs/handling_unit.md b/beam/docs/handling_unit.md index ab498d12..8b062505 100644 --- a/beam/docs/handling_unit.md +++ b/beam/docs/handling_unit.md @@ -4,13 +4,13 @@ For license information, please see license.txt--> # Handling Unit A Handling Unit is an abstraction for tracking quantities of items that are moved or stored together. It does not replace Batch or Serial numbers, the manufacture of an Item, or the functionality of the Product Bundle, but can supplement these as a way of conveniently grabbing information that would otherwise require a lot of keystrokes to enter. -By assigning a unique ID to the Handling Unit, it is possible to capture via scanner the item, net quantity, unit of measure and timestamp of the previous transaction, and then act upon that information in context, according to the [decision matrix](./matrix.md). Beam adds a new doctype, Handling Unit, to implement this functionality in ERPNext. +By assigning a unique ID to the Handling Unit, it is possible to capture via scanner the item, net quantity, unit of measure and timestamp of the previous transaction, and then act upon that information in context, according to the [decision matrix](./matrix.md). BEAM adds a new doctype, Handling Unit, to implement this functionality in ERPNext. ![Screen shot of the Handling Unit doctype listview. The list shows several new Handling Units that were created for items received via a Purchase Receipt.](./assets/handling_unit_list.png) diff --git a/beam/docs/hooks.md b/beam/docs/hooks.md index 537253b1..f3f4adb4 100644 --- a/beam/docs/hooks.md +++ b/beam/docs/hooks.md @@ -1,14 +1,14 @@ -# Extending Beam With Custom Hooks +# Extending BEAM With Custom Hooks -Beam can be extended by adding configurations to your application's `hooks.py`. +BEAM can be extended by adding configurations to your application's `hooks.py`. To make scanning available on a custom doctype, add a table field for "Item Barcode" directly in the doctype or via customize form. Then add a key that is a peer with "Item" in the example below. diff --git a/beam/docs/hu_traceability_report.md b/beam/docs/hu_traceability_report.md index 9a55467d..4e47c0f0 100644 --- a/beam/docs/hu_traceability_report.md +++ b/beam/docs/hu_traceability_report.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Handling Unit Traceability Report diff --git a/beam/docs/index.md b/beam/docs/index.md index 5577d66a..6e5ff86c 100644 --- a/beam/docs/index.md +++ b/beam/docs/index.md @@ -1,18 +1,17 @@ -# Beam +# BEAM - -Beam is a general purpose 2D barcode scanning application for ERPNext. +BEAM is a general purpose barcode scanning application for ERPNext. ## What does this application do? -Beam allows a user to scan a 2D barcode from either a listview or a form view, then helps enter data that would otherwise require numerous keystrokes. Unlike ERPNext's built-in barcode scanning, Beam expects the user to have a hardware barcode scanner connected to their device. +BEAM allows a user to scan a 2D or QR barcode from either a listview or a form view, then helps enter data that would otherwise require numerous keystrokes. Unlike ERPNext's built-in barcode scanning, BEAM expects the user to have a hardware barcode scanner connected to their device. For example, if the user scans a barcode associated with an Item in the Item listview, it will take them to that item's record. @@ -28,22 +27,42 @@ If the user scans an Item in a Delivery Note, it will populate everything it kno Read more about [how scanning in form views works](./form.md). -## Beam Settings +## BEAM Settings -Beam's version 15 introduced a new Beam Settings document to allow users to opt in or out of features in the app. Settings are unique on a per-company basis and are automatically generated (with default options) during certain related transactions if a Beam Settings document doesn't already exist for the company. Related transactions include submission of a Purchase Receipt, Purchase Invoice, or Stock Entry. +Version 15 introduced a new BEAM Settings document to allow users to opt in or out of features in the app. Settings are unique on a per-company basis and are automatically generated (with default options) during certain related transactions if a BEAM Settings document doesn't already exist for the company. Related transactions include submission of a Purchase Receipt, Purchase Invoice, or Stock Entry. -![Screen shot of the Beam Settings document with a field for company and a check box to enable handling units.](./assets/beam_settings.png) +![Screen shot of the BEAM Settings document showing all configuration options including barcode font size, QR code settings, and the Barcode Generation section.](./assets/beam_setings.png) Settings options include: -- **Company:** the company in ERPNext to apply the given settings to. One Beam Settings document may exist for each company in the system +- **Company:** the company in ERPNext to apply the given settings to. One BEAM Settings document may exist for each company in the system - **Enable Handling Units:** (default checked) enables the generation of Handling Units (see What is a Handling Unit section for more information) +- **Enable Scanning of Serial Numbers:** (default unchecked) when enabled, BEAM will resolve scanned barcodes against Serial Number records in addition to Item barcodes +- **Barcode Font Size:** (default 12) controls the font size of the human-readable text rendered beneath Code128 barcode images in print formats + +### QR Code Settings + +- **QR Scale:** (default 8) the module size in pixels used when generating QR code images — larger values produce a bigger image +- **QR Border:** (default 4) the quiet zone border size in modules surrounding the QR code +- **QR Error Correct:** (default M) the error correction level encoded into QR codes; options are L (7%), M (15%), Q (25%), and H (30%) — higher levels allow the code to remain scannable even if partially damaged, at the cost of a denser image + +### Barcode Generation + +The Barcode Generation section controls which document types receive an automatically generated Code128 barcode when saved. Any document type that has a Barcodes table (using the Item Barcode child doctype) is listed here. Checked items have auto-generation **enabled**; unchecked items are shown with a strikethrough and will not have barcodes generated on save. +By default, **Item** and **Warehouse** are enabled. If a Code128 barcode already exists on a document, a new one will never be generated regardless of this setting. If you customize another doctype by adding a Item Barcode table, automatic generation can be configured here but still requires a `doc_event` hook to trigger, which can be configured in your app's `hooks.py` or in a Server Script. +```python +"Asset": { + "validate": [ + "beam.beam.barcodes.create_beam_barcode", + ] +}, +``` ## What is a Handling Unit? A Handling Unit is the combination of a container, any packaging material, and the items within or on it. This could be a pallet of raw materials used in a manufacturing process, a crate containing several other Handling Units, or a delivery vehicle transporting the crates and pallets. -Handling Units have unique, scannable identification numbers that are used in any stock transaction involving the items contained within the unit. The ID allows the user to reference everything about the stock transaction, saved from previous transactions. It also enables you to track the Handling Unit throughout its life cycle. The Beam application includes a [Handling Unit Traceability report](./hu_traceability_report.md) to summarize the transactions, related documents, quantities, and warehouses that involved a given Handling Unit. +Handling Units have unique, scannable identification numbers that are used in any stock transaction involving the items contained within the unit. The ID allows the user to reference everything about the stock transaction, saved from previous transactions. It also enables you to track the Handling Unit throughout its life cycle. The BEAM application includes a [Handling Unit Traceability report](./hu_traceability_report.md) to summarize the transactions, related documents, quantities, and warehouses that involved a given Handling Unit. A Handling Unit is generated when materials are received or created in the manufacturing process. @@ -51,7 +70,7 @@ Read more [about Handling Units here](./handling_unit.md). ## Installation and Customization -Beam comes packed with features, but can be extended with custom hooks both on the server side and in the client as needed. See the following pages for detailed instructions on installing and customizing the application: +BEAM comes packed with features, but can be extended with custom hooks both on the server side and in the client as needed. See the following pages for detailed instructions on installing and customizing the application: - [Installation](https://github.com/agritheory/beam) - [Customization](./hooks.md) @@ -64,7 +83,7 @@ Warehouses may also have unique barcodes associated with them. The user can navi ## Print Server Integration -Beam offers the ability to print to raw input printers like Zebra printers directly from the browser. Also included are several debugging and example print formats. For more details about configuring this, see the [print server section](./print_server.md). +BEAM offers the ability to print to raw input printers like Zebra printers directly from the browser. Also included are several debugging and example print formats. For more details about configuring this, see the [print server section](./print_server.md). ### Zebra Printing diff --git a/beam/docs/listview.md b/beam/docs/listview.md index 7cbb0f1b..8b913928 100644 --- a/beam/docs/listview.md +++ b/beam/docs/listview.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Listview @@ -27,7 +27,7 @@ Another example: If an Item is scanned while viewing the Purchase Receipt list, |Item|Purchase Receipt|filter|item_code| -Beam uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned. +BEAM uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned. Custom actions and client side functions can be added by using [hooks](./hooks.md) diff --git a/beam/docs/matrix.md b/beam/docs/matrix.md index d29daf52..d8558a12 100644 --- a/beam/docs/matrix.md +++ b/beam/docs/matrix.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Listview Actions | Scanned Doctype | Listview | Action | Target | diff --git a/beam/docs/testing.md b/beam/docs/testing.md index 7d9aff4f..e26c2d05 100644 --- a/beam/docs/testing.md +++ b/beam/docs/testing.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Testing diff --git a/beam/docs/zebra_printing.md b/beam/docs/zebra_printing.md index 69559ec8..b8543ae0 100644 --- a/beam/docs/zebra_printing.md +++ b/beam/docs/zebra_printing.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Zebra Printing @@ -14,16 +14,16 @@ To create a Zebra print format, you need the following documents: ### ZPL Code Generation -Currently, only three types of printable ZPL data can be generated with utilities within Beam: +Currently, only three types of printable ZPL data can be generated with utilities within BEAM: - `Text` - `Barcode` - `Label` -Beam uses the [py-zebra-zpl](https://github.com/mtking2/py-zebra-zpl) library to generate the above types, as it provides a basic interface to create ZPL code using Python objects. Please refer to the library's documentation for more information on how to use it. +BEAM uses the [py-zebra-zpl](https://github.com/mtking2/py-zebra-zpl) library to generate the above types, as it provides a basic interface to create ZPL code using Python objects. Please refer to the library's documentation for more information on how to use it. **Note:** Additional ZPL elements (like graphic fields) and commands (text mirroring, character encoding, etc.) can be developed separately and added as text directly to the ZPL Print Format. For more information, visit the [official documentation page](https://supportcommunity.zebra.com/s/article/ZPL-Command-Information-and-DetailsV2?language=en_US) or the [Labelary ZPL Programming Guide](https://labelary.com/zpl.html). -In addition, Beam exposes the following Jinja functions to be used within a Print Format: +In addition, BEAM exposes the following Jinja functions to be used within a Print Format: --- diff --git a/beam/patches/.gitkeep b/beam/patches/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/beam/tests/test_barcode_auto_generate.py b/beam/tests/test_barcode_auto_generate.py new file mode 100644 index 00000000..1115a780 --- /dev/null +++ b/beam/tests/test_barcode_auto_generate.py @@ -0,0 +1,74 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +import pytest + +from beam.beam.barcodes import create_beam_barcode +from beam.beam.doctype.beam_settings.beam_settings import get_doctypes_with_item_barcodes + + +def test_get_doctypes_with_item_barcodes(): + doctypes = get_doctypes_with_item_barcodes() + assert isinstance(doctypes, list) + assert "Item" in doctypes + assert "Warehouse" in doctypes + # all returned values must be real doctypes + for dt in doctypes: + assert frappe.db.exists("DocType", dt), f"Stale DocField reference: '{dt}' does not exist" + + +def _make_item(item_code): + if frappe.db.exists("Item", item_code): + item = frappe.get_doc("Item", item_code) + item.barcodes = [] + return item + item = frappe.new_doc("Item") + item.item_code = item_code + item.item_name = item_code + item.item_group = "All Item Groups" + item.stock_uom = "Nos" + item.is_stock_item = 1 + return item + + +@pytest.fixture() +def beam_settings(): + company = frappe.defaults.get_defaults().get("company") + settings = frappe.get_doc("BEAM Settings", {"company": company}) + original = settings.auto_barcode_doctypes + yield settings + settings.auto_barcode_doctypes = original + settings.save() + + +def test_barcode_generated_when_doctype_allowed(beam_settings): + beam_settings.auto_barcode_doctypes = '["Item", "Warehouse"]' + beam_settings.save() + + item = _make_item("_Test Barcode Allow Item") + create_beam_barcode(item) + + assert any(b.barcode_type == "Code128" for b in item.barcodes) + + +def test_barcode_not_generated_when_doctype_not_allowed(beam_settings): + beam_settings.auto_barcode_doctypes = '["Warehouse"]' + beam_settings.save() + + item = _make_item("_Test Barcode Disallow Item") + create_beam_barcode(item) + + assert not any(b.barcode_type == "Code128" for b in item.barcodes) + + +def test_barcode_not_duplicated_when_code128_exists(beam_settings): + beam_settings.auto_barcode_doctypes = '["Item", "Warehouse"]' + beam_settings.save() + + item = _make_item("_Test Barcode Dedup Item") + item.append("barcodes", {"barcode": "12345678901234567890", "barcode_type": "Code128"}) + create_beam_barcode(item) + + code128_barcodes = [b for b in item.barcodes if b.barcode_type == "Code128"] + assert len(code128_barcodes) == 1 From c2579e054c1a7d9a4f15a62c1bcf61bee2ea6785 Mon Sep 17 00:00:00 2001 From: AgriTheory Date: Sat, 28 Feb 2026 14:34:56 +0000 Subject: [PATCH 21/21] 15.8.0 Automatically generated by python-semantic-release --- beam/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beam/__init__.py b/beam/__init__.py index d6da05fe..51b884c0 100644 --- a/beam/__init__.py +++ b/beam/__init__.py @@ -1,4 +1,4 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt -__version__ = "15.7.0" +__version__ = "15.8.0" diff --git a/pyproject.toml b/pyproject.toml index a86bd506..a1b2f866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beam" -version = "15.7.0" +version = "15.8.0" authors = [ { name = "AgriTheory", email = "support@agritheory.dev" } ]