From f07e4b5521403965237f191e5d07809d85c3aeb3 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Mon, 11 Aug 2025 18:28:25 -0400 Subject: [PATCH 1/3] wip: usb scale --- .pre-commit-config.yaml | 32 ++++++++------------ beam/docs/scale.md | 7 +++++ beam/public/js/beam.bundle.js | 1 + beam/public/js/scale/scale.js | 57 +++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 beam/docs/scale.md create mode 100644 beam/public/js/scale/scale.js diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aa7cc233..773a9ff6 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: v4.3.0 hooks: - id: trailing-whitespace files: 'beam.*' @@ -20,42 +20,43 @@ repos: - id: debug-statements - repo: https://github.com/asottile/pyupgrade - rev: v3.19.1 + rev: v2.34.0 hooks: - id: pyupgrade - args: ['--py310-plus'] + args: ['--py38-plus'] - - repo: https://github.com/agritheory/black + - repo: https://github.com/frappe/black rev: 951ccf4d5bb0d692b457a5ebc4215d755618eb68 hooks: - id: black - - repo: https://github.com/PyCQA/autoflake - rev: v2.3.1 + - repo: local hooks: - - id: autoflake - args: [--remove-all-unused-imports, --in-place] + - id: prettier + name: prettier + entry: npx prettier . --write --ignore-path .prettierignore + language: node - repo: https://github.com/PyCQA/isort - rev: 6.0.0 + rev: 5.12.0 hooks: - id: isort - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.3.0 hooks: - id: codespell additional_dependencies: - tomli - repo: https://github.com/agritheory/test_utils - rev: v0.17.0 + rev: v1.0.0 hooks: - id: update_pre_commit_config - id: validate_copyright @@ -65,13 +66,6 @@ repos: args: ['--app', 'beam'] - id: validate_customizations - - repo: local - hooks: - - id: prettier - name: prettier - entry: npx prettier . --write --ignore-path .prettierignore - language: node - ci: autoupdate_schedule: weekly skip: [] diff --git a/beam/docs/scale.md b/beam/docs/scale.md new file mode 100644 index 00000000..18c8ed6b --- /dev/null +++ b/beam/docs/scale.md @@ -0,0 +1,7 @@ + + +# Using the scale features + +The WebHID protocol is not supported in all browsers, [look here for compatibility](https://developer.mozilla.org/en-US/docs/Web/API/WebHID_API#browser_compatibility) + diff --git a/beam/public/js/beam.bundle.js b/beam/public/js/beam.bundle.js index 70eaa466..4a3b6e4d 100644 --- a/beam/public/js/beam.bundle.js +++ b/beam/public/js/beam.bundle.js @@ -4,3 +4,4 @@ import './scan/scan.js' import './print/print.js' // import './example_custom_callback.js' +import './scale/scale.js' diff --git a/beam/public/js/scale/scale.js b/beam/public/js/scale/scale.js new file mode 100644 index 00000000..b4203631 --- /dev/null +++ b/beam/public/js/scale/scale.js @@ -0,0 +1,57 @@ +// Copyright (c) 2025, AgriTheory and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Purchase Receipt', { + async refresh(frm) { + await setup_scale(frm) + }, +}) + +async function setup_scale(frm) { + $('.form-sidebar').append(`
+

0.0 oz

+ +
`) + let scale_readout = $('#scale-readout') + $('#use-scale').on('click', () => use_scale(scale_readout)) + let array_of_weight_readings = [] + let weighed = false + let _mode = undefined + async function use_scale(scale_readout) { + $('#use-scale').hide() + const device = (await navigator.hid.requestDevice({ filters: [] }))?.[0] + await device.open() + + // think of this as a while loop + device.oninputreport = report => { + // check for "exit" or "weighed" flag + const { value, unit } = parseScaleData(report.data) + // detect if last __ on array are <= 0 as signal to move to next item + // + array_of_weight_readings.push(value) + _mode = scale_readout.html(`${value} ${unit}`) + } + } +} + +function parseScaleData(data) { + const sign = Number(data.getUint8(0)) == 4 ? 1 : -1 // 4 = positive, 5 = negative, 2 = zero + const unit = Number(data.getUint8(1)) == 2 ? 'g' : 'oz' // 2 = g, 11 = oz + const value = Number(data.getUint16(3, true)) // this one needs little endian + return { value: sign * (unit == 'oz' ? value / 10 : value), unit } +} + +function mode(arr) { + if (arr.filter((x, index) => arr.indexOf(x) == index).length == arr.length) return arr + else + return mode( + arr + .sort((x, index) => x - index) + .map((x, index) => (arr.indexOf(x) != index ? x : null)) + .filter(x => x != null) + ) +} + +function set_qty_uom_and_focus_on_next_qty_field(frm, qty, uom) { + console.log(qty, uom) +} From 4d972bbe84f566f30c59ec2352ee9b573c7a5268 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Wed, 17 Jun 2026 17:03:02 -0400 Subject: [PATCH 2/3] wip: iterate weighed items and scale settings config --- beam/beam/boot.py | 41 ++ .../beam_scale_doctype_config/__init__.py | 2 + .../beam_scale_doctype_config.json | 58 ++ .../beam_scale_doctype_config.py | 8 + .../doctype/beam_settings/beam_settings.json | 15 +- beam/public/js/scale/scale.js | 506 ++++++++++++++++-- beam/tests/setup.py | 55 ++ 7 files changed, 647 insertions(+), 38 deletions(-) create mode 100644 beam/beam/doctype/beam_scale_doctype_config/__init__.py create mode 100644 beam/beam/doctype/beam_scale_doctype_config/beam_scale_doctype_config.json create mode 100644 beam/beam/doctype/beam_scale_doctype_config/beam_scale_doctype_config.py diff --git a/beam/beam/boot.py b/beam/beam/boot.py index 22cb4b11..1853cb7e 100644 --- a/beam/beam/boot.py +++ b/beam/beam/boot.py @@ -9,6 +9,8 @@ def boot_session(bootinfo): bootinfo.beam = get_scan_doctypes() bootinfo.beam["settings"] = get_beam_settings() + bootinfo.beam["mass_uoms"] = get_mass_uoms() + bootinfo.beam["scale_configs"] = get_scale_configs() bootinfo.beam["default_hu_print_format"] = frappe.get_meta("Handling Unit").get( "default_print_format" ) @@ -26,3 +28,42 @@ def get_beam_settings(): "enable_handling_units": setting.enable_handling_units, } return settings + + +def get_mass_uoms(): + """Get all UOMs that have a UOM Conversion Factor record with category='Mass'.""" + return frappe.get_all( + "UOM Conversion Factor", + filters={"category": "Mass"}, + pluck="from_uom", + distinct=True, + ) + + +def get_scale_configs(): + """Return scale_doctype_configs keyed by doctype_name.""" + configs = {} + beam_settings_list = frappe.get_all( + "BEAM Settings", + fields=["name"], + ) + for setting in beam_settings_list: + scale_configs = frappe.get_all( + "BEAM Scale Doctype Config", + filters={"parent": setting.name}, + fields=[ + "doctype_name", + "qty_field", + "items_table_field", + "zero_threshold", + "autoadvance_on_zero", + ], + ) + for config in scale_configs: + configs[config.doctype_name] = { + "qty_field": config.qty_field, + "items_table_field": config.items_table_field, + "zero_threshold": config.zero_threshold, + "autoadvance_on_zero": config.autoadvance_on_zero, + } + return configs diff --git a/beam/beam/doctype/beam_scale_doctype_config/__init__.py b/beam/beam/doctype/beam_scale_doctype_config/__init__.py new file mode 100644 index 00000000..ea2ab3f3 --- /dev/null +++ b/beam/beam/doctype/beam_scale_doctype_config/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt diff --git a/beam/beam/doctype/beam_scale_doctype_config/beam_scale_doctype_config.json b/beam/beam/doctype/beam_scale_doctype_config/beam_scale_doctype_config.json new file mode 100644 index 00000000..3b9bed92 --- /dev/null +++ b/beam/beam/doctype/beam_scale_doctype_config/beam_scale_doctype_config.json @@ -0,0 +1,58 @@ +{ + "actions": [], + "creation": "2026-06-17 16:07:30.123456", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "doctype_name", + "qty_field", + "items_table_field", + "zero_threshold", + "autoadvance_on_zero" + ], + "fields": [ + { + "fieldname": "doctype_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Doctype Name", + "reqd": 1 + }, + { + "fieldname": "qty_field", + "fieldtype": "Data", + "label": "Qty Field", + "default": "qty" + }, + { + "fieldname": "items_table_field", + "fieldtype": "Data", + "label": "Items Table Field", + "default": "items" + }, + { + "fieldname": "zero_threshold", + "fieldtype": "Float", + "label": "Zero Threshold (oz)", + "default": "0.1" + }, + { + "fieldname": "autoadvance_on_zero", + "fieldtype": "Check", + "label": "Auto Advance on Zero", + "default": "1" + } + ], + "istable": 1, + "links": [], + "modified": "2026-06-17 16:07:30.123456", + "modified_by": "Administrator", + "module": "BEAM", + "name": "BEAM Scale Doctype Config", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/beam/beam/doctype/beam_scale_doctype_config/beam_scale_doctype_config.py b/beam/beam/doctype/beam_scale_doctype_config/beam_scale_doctype_config.py new file mode 100644 index 00000000..ab911848 --- /dev/null +++ b/beam/beam/doctype/beam_scale_doctype_config/beam_scale_doctype_config.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class BEAMScaleDoctypeConfig(Document): + pass diff --git a/beam/beam/doctype/beam_settings/beam_settings.json b/beam/beam/doctype/beam_settings/beam_settings.json index d53693f7..45a91c71 100644 --- a/beam/beam/doctype/beam_settings/beam_settings.json +++ b/beam/beam/doctype/beam_settings/beam_settings.json @@ -16,7 +16,9 @@ "barcode_generation_section", "barcode_exclusions_html", "column_break_barcode", - "auto_barcode_doctypes" + "auto_barcode_doctypes", + "scale_tab", + "scale_doctype_configs" ], "fields": [ { @@ -89,6 +91,17 @@ "fieldtype": "JSON", "hidden": 1, "label": "Auto Barcode Doctypes" + }, + { + "fieldname": "scale_tab", + "fieldtype": "Tab Break", + "label": "Scale" + }, + { + "fieldname": "scale_doctype_configs", + "fieldtype": "Table", + "label": "Scale Doctype Configs", + "options": "BEAM Scale Doctype Config" } ], "links": [], diff --git a/beam/public/js/scale/scale.js b/beam/public/js/scale/scale.js index b4203631..4b6162c9 100644 --- a/beam/public/js/scale/scale.js +++ b/beam/public/js/scale/scale.js @@ -7,51 +7,483 @@ frappe.ui.form.on('Purchase Receipt', { }, }) +// Global scale state +const scaleState = { + device: null, + frm: null, + connected: false, + config: null, + activeRowIdx: null, + workflowState: 'IDLE', + weightValue: 0, + weightUnit: 'oz', + statusCode: 0, + reportCount: 0, + massUoms: [], + conversionFactors: {}, + overlay: null, + sidebarWidget: null, + handlersBound: false, + lastReadingKey: null, + stableReadingCount: 0, + qtyFilled: false, + lastStatusText: '', +} + async function setup_scale(frm) { - $('.form-sidebar').append(`
-

0.0 oz

- -
`) - let scale_readout = $('#scale-readout') - $('#use-scale').on('click', () => use_scale(scale_readout)) - let array_of_weight_readings = [] - let weighed = false - let _mode = undefined - async function use_scale(scale_readout) { - $('#use-scale').hide() - const device = (await navigator.hid.requestDevice({ filters: [] }))?.[0] - await device.open() - - // think of this as a while loop - device.oninputreport = report => { - // check for "exit" or "weighed" flag - const { value, unit } = parseScaleData(report.data) - // detect if last __ on array are <= 0 as signal to move to next item - // - array_of_weight_readings.push(value) - _mode = scale_readout.html(`${value} ${unit}`) + if (frm.doc.docstatus) return + + // Always update frm reference so the active form is current + scaleState.frm = frm + scaleState.massUoms = frappe.boot.beam.mass_uoms || [] + scaleState.config = (frappe.boot.beam.scale_configs || {})[frm.doctype] + + if (!scaleState.config) return + + ensureSidebarWidget(frm) + ensureScalePopover() + + // If already connected, restore visible state without re-opening device + if (scaleState.connected) { + updateConnectionStatus() + scaleState.overlay.style.display = 'block' + updateRowHighlight() + } +} + +function ensureSidebarWidget(frm) { + // Remove any stale widget left by a previous frm render + document.querySelectorAll('#scale-sidebar-widget').forEach(el => el.remove()) + + const wrapper = document.createElement('div') + wrapper.id = 'scale-sidebar-widget' + wrapper.innerHTML = ` +
+
+ + Not connected +
+ +
+ Raw HID + +
+
+ ` + + const $sidebar = frm.sidebar && frm.sidebar.sidebar + if ($sidebar && $sidebar.length) { + $sidebar.prepend(wrapper) + scaleState.sidebarWidget = wrapper + } + + document.getElementById('use-scale-btn').addEventListener('click', () => { + use_scale(frm) + }) +} + +function ensureScalePopover() { + // Reuse existing popover across form navigations + const existing = document.getElementById('scale-row-popover') + if (existing) { + scaleState.overlay = existing + return + } + + const popover = document.createElement('div') + popover.id = 'scale-row-popover' + popover.style.cssText = ` + position: fixed; + right: 24px; + top: 100px; + width: 240px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-left: 4px solid var(--primary-color); + border-radius: 6px; + padding: 16px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + z-index: 2000; + transition: top 200ms ease; + display: none; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + ` + popover.innerHTML = ` +
+ [—] +
+
+ + +
+
+
+ ` + document.body.appendChild(popover) + scaleState.overlay = popover +} + +async function use_scale(frm) { + // Already open — just show the popover and update state + if (scaleState.connected && scaleState.device) { + updateConnectionStatus() + scaleState.overlay.style.display = 'block' + document.getElementById('use-scale-btn').style.display = 'none' + updateRowHighlight() + return + } + + const btn = document.getElementById('use-scale-btn') + btn.disabled = true + btn.innerHTML = 'Connecting...' + + try { + const devices = await navigator.hid.requestDevice({ + filters: [{ vendorId: 0x1446 }, { vendorId: 0x11ff }], + }) + + if (devices.length === 0) { + frappe.msgprint('No scale device selected') + btn.disabled = false + btn.innerHTML = 'USE SCALE' + return + } + + scaleState.device = devices[0] + await scaleState.device.open() + scaleState.connected = true + scaleState.workflowState = 'WAITING' + scaleState.activeRowIdx = 0 + + await loadConversionFactors(frm) + + updateConnectionStatus() + updateRowHighlight() + btn.style.display = 'none' + scaleState.overlay.style.display = 'block' + + if (!scaleState.handlersBound) { + bindScaleInputHandler() + scaleState.handlersBound = true } + } catch (error) { + console.error('Scale connection failed:', error) + frappe.msgprint(`Scale connection failed: ${error.message}`) + btn.disabled = false + btn.innerHTML = 'USE SCALE' + } +} + +function bindScaleInputHandler() { + scaleState.device.oninputreport = report => { + scaleState.reportCount++ + const parsed = parseScaleData(report.data) + const readingKey = `${parsed.raw}:${parsed.status}` + + if (readingKey === scaleState.lastReadingKey) { + return + } + scaleState.lastReadingKey = readingKey + + scaleState.weightValue = parsed.value + scaleState.weightUnit = parsed.unit + scaleState.statusCode = parsed.status + scaleState.lastRawHex = rawBytesToHex(report.data) + + handleWorkflowState(parsed) + updatePopover() + updateSidebarWidget() } } function parseScaleData(data) { - const sign = Number(data.getUint8(0)) == 4 ? 1 : -1 // 4 = positive, 5 = negative, 2 = zero - const unit = Number(data.getUint8(1)) == 2 ? 'g' : 'oz' // 2 = g, 11 = oz - const value = Number(data.getUint16(3, true)) // this one needs little endian - return { value: sign * (unit == 'oz' ? value / 10 : value), unit } + const status = data.getUint8(0) + const unitCode = data.getUint8(1) + const exponent = data.getInt8(2) + const raw = data.getUint16(3, true) + const value = raw * Math.pow(10, exponent) + + const unit = unitCode === 11 ? 'oz' : unitCode === 12 ? 'lb' : unitCode === 2 ? 'g' : 'units' + + return { value, unit, status, raw, exponent } +} + +function rawBytesToHex(data) { + return Array.from(data) + .map(b => b.toString(16).padStart(2, '0').toUpperCase()) + .join(' ') +} + +function updateConnectionStatus() { + const dot = document.getElementById('scale-connection-dot') + const text = document.getElementById('scale-connection-text') + if (scaleState.connected) { + dot.style.background = '#4CAF50' + text.textContent = 'Connected' + } else { + dot.style.background = '#ccc' + text.textContent = 'Not connected' + } +} + +function updateSidebarWidget() { + const rawHex = document.getElementById('scale-raw-hex') + if (rawHex) { + rawHex.textContent = scaleState.lastRawHex || '—' + } +} + +function updatePopover() { + if (!scaleState.overlay) return + + const isStable = scaleState.statusCode === 4 + + const valueEl = document.getElementById('scale-readout-value') + const unitEl = document.getElementById('scale-readout-unit') + if (isStable) { + valueEl.textContent = scaleState.weightValue.toFixed(1) + unitEl.textContent = scaleState.weightUnit + } else { + valueEl.textContent = '…' + unitEl.textContent = '' + } + + if (scaleState.activeRowIdx !== null) { + const itemsTableField = scaleState.config.items_table_field || 'items' + const items = scaleState.frm.doc[itemsTableField] + + if (items && items[scaleState.activeRowIdx]) { + const row = items[scaleState.activeRowIdx] + const itemName = row.item_name || row.item_code || '?' + const rowNum = scaleState.activeRowIdx + 1 + const totalRows = items.length + + document.getElementById('scale-item-name').textContent = itemName + document.getElementById('scale-row-progress').textContent = `[${rowNum}/${totalRows}]` + + const rowUom = row.uom || '' + if (isStable && scaleState.massUoms.includes(rowUom)) { + const converted = convertWeight(scaleState.weightValue, scaleState.weightUnit, rowUom) + document.getElementById('scale-readout-converted').textContent = converted + } else { + document.getElementById('scale-readout-converted').textContent = '' + } + } + } + + document.getElementById('scale-status-text').textContent = scaleState.lastStatusText + updateRowHighlight() +} + +function formatWeight(value, unit) { + if (value === 0 || value < 0) { + return '0 ' + unit + } + + if (unit === 'oz') { + const lbs = Math.floor(value / 16) + const oz = value % 16 + if (lbs > 0) { + return `${lbs.toFixed(0)} lb ${oz.toFixed(1)} oz` + } + } + + return `${value.toFixed(1)} ${unit}` +} + +async function loadConversionFactors(frm) { + const itemsTableField = scaleState.config.items_table_field || 'items' + const items = frm.doc[itemsTableField] || [] + const uoms = [...new Set(items.map(r => r.uom).filter(u => scaleState.massUoms.includes(u)))] + + for (const toUom of uoms) { + if (scaleState.conversionFactors[toUom] !== undefined) continue + // Try Ounce → toUom, then Pound → toUom + for (const fromUom of ['Ounce', 'Pound']) { + const result = await frappe.db.get_value('UOM Conversion Factor', { from_uom: fromUom, to_uom: toUom }, 'value') + if (result && result.message && result.message.value) { + scaleState.conversionFactors[toUom] = { value: result.message.value, from: fromUom } + break + } + } + // Fall back: try inverse (toUom → Ounce) + if (scaleState.conversionFactors[toUom] === undefined) { + const result = await frappe.db.get_value('UOM Conversion Factor', { from_uom: toUom, to_uom: 'Ounce' }, 'value') + if (result && result.message && result.message.value) { + scaleState.conversionFactors[toUom] = { value: 1 / result.message.value, from: 'Ounce' } + } + } + } +} + +function convertWeight(value, fromUnit, toUom) { + if (!scaleState.massUoms.includes(toUom)) return '' + + const entry = scaleState.conversionFactors[toUom] + if (!entry) return '' + + // Convert value to the same "from" unit the factor uses + let inOz = fromUnit === 'oz' ? value : value * 16 + let inFrom = entry.from === 'Ounce' ? inOz : inOz / 16 + + const converted = inFrom * entry.value + return `${converted.toFixed(3)} ${toUom}` +} + +function handleWorkflowState(parsed) { + const isStable = parsed.status === 4 + const zeroThreshold = scaleState.config.zero_threshold || 0.1 + const isZero = parsed.value < zeroThreshold + const itemsTableField = scaleState.config.items_table_field || 'items' + const items = scaleState.frm.doc[itemsTableField] + + if (!items || items.length === 0) { + scaleState.workflowState = 'IDLE' + scaleState.lastStatusText = 'No items to weigh' + return + } + + if (scaleState.activeRowIdx === null) { + scaleState.activeRowIdx = 0 + } + + switch (scaleState.workflowState) { + case 'IDLE': + scaleState.workflowState = 'WAITING' + scaleState.lastStatusText = 'Place item on scale' + break + + case 'WAITING': + if (isStable && !isZero) { + scaleState.workflowState = 'STABLE' + scaleState.stableReadingCount = 1 + scaleState.lastStatusText = 'Stable' + } else { + scaleState.lastStatusText = 'Place item on scale' + } + break + + case 'STABLE': + if (isStable && !isZero) { + scaleState.stableReadingCount++ + if (scaleState.stableReadingCount >= 2) { + scaleState.workflowState = 'FILL_QTY' + fillQtyFromWeight() + } else { + scaleState.lastStatusText = `Stable (${scaleState.stableReadingCount}/2)` + } + } else { + scaleState.workflowState = 'WAITING' + scaleState.lastStatusText = 'Place item on scale' + } + break + + case 'FILL_QTY': + if (!scaleState.qtyFilled) { + scaleState.qtyFilled = true + } + scaleState.workflowState = 'WAITING_FOR_ZERO' + scaleState.lastStatusText = 'Remove item' + break + + case 'WAITING_FOR_ZERO': + if (isZero) { + scaleState.workflowState = 'ZERO_DETECTED' + scaleState.lastStatusText = 'Ready for next' + } else { + scaleState.lastStatusText = 'Remove item' + } + break + + case 'ZERO_DETECTED': + if (scaleState.config.autoadvance_on_zero) { + advanceToNextRow() + } else { + scaleState.lastStatusText = 'Ready for next' + } + break + } +} + +function fillQtyFromWeight() { + if (scaleState.activeRowIdx === null) return + + const itemsTableField = scaleState.config.items_table_field || 'items' + const qtyField = scaleState.config.qty_field || 'qty' + const items = scaleState.frm.doc[itemsTableField] + + if (!items || !items[scaleState.activeRowIdx]) return + + const row = items[scaleState.activeRowIdx] + const uomField = scaleState.config.uom_field || 'uom' + const uom = row[uomField] + + let qtyToSet = scaleState.weightValue + + if (scaleState.massUoms.includes(uom)) { + const entry = scaleState.conversionFactors[uom] + if (entry) { + let inOz = scaleState.weightUnit === 'oz' ? scaleState.weightValue : scaleState.weightValue * 16 + let inFrom = entry.from === 'Ounce' ? inOz : inOz / 16 + qtyToSet = inFrom * entry.value + } + } + + frappe.model.set_value(row.doctype, row.name, qtyField, qtyToSet) + scaleState.lastStatusText = `Set qty: ${qtyToSet.toFixed(3)}` } -function mode(arr) { - if (arr.filter((x, index) => arr.indexOf(x) == index).length == arr.length) return arr - else - return mode( - arr - .sort((x, index) => x - index) - .map((x, index) => (arr.indexOf(x) != index ? x : null)) - .filter(x => x != null) - ) +function advanceToNextRow() { + const itemsTableField = scaleState.config.items_table_field || 'items' + const items = scaleState.frm.doc[itemsTableField] + + if (!items) return + + scaleState.activeRowIdx = (scaleState.activeRowIdx + 1) % items.length + + if (scaleState.activeRowIdx === 0) { + scaleState.lastStatusText = 'All items done' + } else { + scaleState.workflowState = 'WAITING' + scaleState.stableReadingCount = 0 + scaleState.qtyFilled = false + scaleState.lastStatusText = 'Place item on scale' + } + + updateRowHighlight() } -function set_qty_uom_and_focus_on_next_qty_field(frm, qty, uom) { - console.log(qty, uom) +function updateRowHighlight() { + const itemsTableField = scaleState.config.items_table_field || 'items' + const field = scaleState.frm.get_field(itemsTableField) + + if (!field || !field.grid) return + + const gridRows = field.grid.grid_rows || [] + + gridRows.forEach((row, idx) => { + if (!row.wrapper) return + // row.wrapper is a jQuery object + if (idx === scaleState.activeRowIdx) { + row.wrapper.addClass('scale-row-active') + } else { + row.wrapper.removeClass('scale-row-active') + } + }) + + if (scaleState.overlay && scaleState.activeRowIdx !== null && gridRows[scaleState.activeRowIdx]) { + const activeWrapper = gridRows[scaleState.activeRowIdx].wrapper + if (activeWrapper && activeWrapper.length) { + // rect.top is already in viewport coordinates for position:fixed + const rect = activeWrapper[0].getBoundingClientRect() + scaleState.overlay.style.top = `${Math.max(8, rect.top)}px` + } + } } + +frappe.dom.set_style(` + .scale-row-active { + background: var(--yellow-highlight-color) !important; + outline: 2px solid var(--yellow-100) !important; + } +`) diff --git a/beam/tests/setup.py b/beam/tests/setup.py index 4cf7dacb..14dcb687 100644 --- a/beam/tests/setup.py +++ b/beam/tests/setup.py @@ -83,6 +83,8 @@ def create_test_data(): create_suppliers(settings) create_customers(settings) create_items(settings) + create_mass_uom_conversions() + create_scale_doctype_configs(settings) create_boms(settings) prod_plan_from_doc = "Sales Order" if prod_plan_from_doc == "Sales Order": @@ -608,6 +610,59 @@ def create_purchase_receipt_for_received_qty_test(settings): pr.save() +def create_mass_uom_conversions(): + """Create UOM Conversion Factor records for mass units with category='Mass'.""" + mass_conversions = [ + ("Pound", "Ounce", 16.0), + ("Gram", "Ounce", 0.035274), + ("Kg", "Gram", 1000.0), + ] + for from_uom, to_uom, value in mass_conversions: + if not frappe.db.exists("UOM", from_uom) or not frappe.db.exists("UOM", to_uom): + continue + if not frappe.db.exists("UOM Conversion Factor", {"from_uom": from_uom, "to_uom": to_uom}): + frappe.get_doc( + { + "doctype": "UOM Conversion Factor", + "from_uom": from_uom, + "to_uom": to_uom, + "value": value, + "category": "Mass", + } + ).insert() + + +def create_scale_doctype_configs(settings): + """Create BEAM Scale Doctype Config for Purchase Receipt.""" + company = settings.company + + try: + if not frappe.db.exists("BEAM Settings", company): + beam_settings = frappe.new_doc("BEAM Settings") + beam_settings.name = company + beam_settings.company = company + beam_settings.save() + + beam_settings = frappe.get_doc("BEAM Settings", company) + + existing_configs = [row.doctype_name for row in beam_settings.scale_doctype_configs or []] + + if "Purchase Receipt" not in existing_configs: + beam_settings.append( + "scale_doctype_configs", + { + "doctype_name": "Purchase Receipt", + "qty_field": "qty", + "items_table_field": "items", + "zero_threshold": 0.1, + "autoadvance_on_zero": 1, + }, + ) + beam_settings.save() + except Exception: + pass + + def create_network_printer_settings(settings): printer_settings = [ {"name": "Receiving Printer", "server_ip": "localhost", "port": 8888}, From 3d854a18615cd1511e9c37f63840e75b3a3dd6be Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Wed, 24 Jun 2026 14:21:27 -0400 Subject: [PATCH 3/3] feat: better iterator --- .pre-commit-config.yaml | 2 +- beam/public/js/scale/scale.js | 431 ++++++++++++++++++++++++++++++---- 2 files changed, 385 insertions(+), 48 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 620f09a2..59696596 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,7 +53,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.25.1 + rev: v1.28.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/beam/public/js/scale/scale.js b/beam/public/js/scale/scale.js index 4b6162c9..335f3e48 100644 --- a/beam/public/js/scale/scale.js +++ b/beam/public/js/scale/scale.js @@ -5,12 +5,43 @@ frappe.ui.form.on('Purchase Receipt', { async refresh(frm) { await setup_scale(frm) }, + items_add(frm, cdt, cdn) { + if (!scaleState.connected) return + + const itemsTableField = (scaleState.config && scaleState.config.items_table_field) || 'items' + const items = frm.doc[itemsTableField] || [] + const newRowIdx = items.findIndex(r => r.name === cdn) + if (newRowIdx < 0) return + + focusScaleRow(newRowIdx) + }, }) +function resetScaleIterator() { + scaleState.activeRowIdx = 0 + scaleState.workflowState = 'WAITING' + scaleState.stableReadingCount = 0 + scaleState.qtyFilled = false + scaleState.lastStatusText = 'Place item on scale' + scaleState.lastReadingKey = null +} + +function focusScaleRow(rowIdx) { + scaleState.activeRowIdx = rowIdx + scaleState.workflowState = 'WAITING' + scaleState.stableReadingCount = 0 + scaleState.qtyFilled = false + scaleState.lastStatusText = 'Place item on scale' + scaleState.lastReadingKey = null + updatePopover() + updateRowHighlight() +} + // Global scale state const scaleState = { device: null, frm: null, + lastDocKey: null, connected: false, config: null, activeRowIdx: null, @@ -24,6 +55,9 @@ const scaleState = { overlay: null, sidebarWidget: null, handlersBound: false, + rowFocusFrm: null, + positionListenersBound: false, + popoverResizeObserver: null, lastReadingKey: null, stableReadingCount: 0, qtyFilled: false, @@ -33,24 +67,69 @@ const scaleState = { async function setup_scale(frm) { if (frm.doc.docstatus) return + const docKey = `${frm.doctype}:${frm.doc.name}` + const docChanged = scaleState.lastDocKey !== null && scaleState.lastDocKey !== docKey + // Always update frm reference so the active form is current scaleState.frm = frm + scaleState.lastDocKey = docKey scaleState.massUoms = frappe.boot.beam.mass_uoms || [] scaleState.config = (frappe.boot.beam.scale_configs || {})[frm.doctype] if (!scaleState.config) return + if (docChanged && scaleState.connected) { + resetScaleIterator() + loadConversionFactors(frm) + } + ensureSidebarWidget(frm) ensureScalePopover() + bindScaleRowFocus(frm) // If already connected, restore visible state without re-opening device if (scaleState.connected) { updateConnectionStatus() scaleState.overlay.style.display = 'block' + updatePopover() updateRowHighlight() } } +function bindScaleRowFocus(frm) { + if (scaleState.rowFocusFrm === frm) return + + if (scaleState.rowFocusFrm) { + $(scaleState.rowFocusFrm.wrapper).off('.scale') + } + scaleState.rowFocusFrm = frm + + $(frm.wrapper).on('focusin.scale click.scale', function (e) { + if (!scaleState.connected || !scaleState.config) return + if (scaleState.frm !== frm) return + + const itemsTableField = scaleState.config.items_table_field || 'items' + const field = frm.get_field(itemsTableField) + if (!field || !field.grid) return + + const $target = $(e.target) + if (!$target.closest(field.grid.wrapper).length) return + if ($target.hasClass('grid-row-check')) return + + const $gridRow = $target.closest('.grid-row') + if (!$gridRow.length) return + + const gridRow = $gridRow.data('grid_row') + if (!gridRow || !gridRow.doc) return + + const items = frm.doc[itemsTableField] || [] + const rowIdx = items.findIndex(r => r.name === gridRow.doc.name) + if (rowIdx < 0 || rowIdx === scaleState.activeRowIdx) return + + focusScaleRow(rowIdx) + }) +} + function ensureSidebarWidget(frm) { // Remove any stale widget left by a previous frm render document.querySelectorAll('#scale-sidebar-widget').forEach(el => el.remove()) @@ -83,44 +162,157 @@ function ensureSidebarWidget(frm) { } function ensureScalePopover() { - // Reuse existing popover across form navigations const existing = document.getElementById('scale-row-popover') - if (existing) { + if (existing && existing.querySelector('.scale-popover-caret') && existing.querySelector('.scale-popover-bridge')) { scaleState.overlay = existing + bindScalePopoverPositionListeners() return } + if (existing) { + existing.remove() + } const popover = document.createElement('div') popover.id = 'scale-row-popover' - popover.style.cssText = ` - position: fixed; - right: 24px; - top: 100px; - width: 240px; - background: var(--card-bg); - border: 1px solid var(--border-color); - border-left: 4px solid var(--primary-color); - border-radius: 6px; - padding: 16px; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); - z-index: 2000; - transition: top 200ms ease; - display: none; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - ` + popover.className = 'scale-popover scale-popover--caret-right' + popover.style.display = 'none' popover.innerHTML = ` -
- [—] -
-
- - +
+
+
+
+ [—] +
+
+ + +
+
+
-
-
` document.body.appendChild(popover) scaleState.overlay = popover + bindScalePopoverPositionListeners() +} + +function bindScalePopoverPositionListeners() { + if (scaleState.positionListenersBound) return + scaleState.positionListenersBound = true + + const reposition = () => { + if (scaleState.connected && scaleState.overlay && scaleState.overlay.style.display !== 'none') { + positionScalePopover() + } + } + + window.addEventListener('scroll', reposition, true) + window.addEventListener('resize', reposition) + + if (typeof ResizeObserver !== 'undefined' && scaleState.overlay) { + scaleState.popoverResizeObserver = new ResizeObserver(reposition) + scaleState.popoverResizeObserver.observe(scaleState.overlay) + } +} + +function positionScalePopover() { + if (!scaleState.overlay || !scaleState.frm || scaleState.activeRowIdx === null || !scaleState.config) { + return + } + + const itemsTableField = scaleState.config.items_table_field || 'items' + const field = scaleState.frm.get_field(itemsTableField) + if (!field || !field.grid) return + + const gridRows = field.grid.grid_rows || [] + const gridRow = gridRows[scaleState.activeRowIdx] + if (!gridRow || !gridRow.wrapper || !gridRow.wrapper.length) return + + const rowRect = gridRow.wrapper[0].getBoundingClientRect() + const popover = scaleState.overlay + const popoverH = popover.offsetHeight + const popoverW = popover.offsetWidth || 240 + const caretSize = 20 + const gap = 8 + const pad = 8 + const caretHeight = 40 + + const rowCenterY = rowRect.top + rowRect.height / 2 + let top = rowCenterY - popoverH / 2 + top = Math.max(pad, Math.min(top, window.innerHeight - popoverH - pad)) + + const gridRect = field.grid.wrapper[0].getBoundingClientRect() + const spaceLeft = gridRect.left - pad + const spaceRight = window.innerWidth - gridRect.right - pad + const caret = popover.querySelector('.scale-popover-caret') + const bridge = popover.querySelector('.scale-popover-bridge') + + let left + let caretOnRight = true + if (spaceLeft >= popoverW + gap + caretSize) { + left = gridRect.left - popoverW - gap - caretSize + popover.classList.remove('scale-popover--caret-left') + popover.classList.add('scale-popover--caret-right') + caretOnRight = true + } else if (spaceRight >= popoverW + gap + caretSize) { + left = gridRect.right + gap + caretSize + popover.classList.remove('scale-popover--caret-right') + popover.classList.add('scale-popover--caret-left') + caretOnRight = false + } else { + left = pad + popover.classList.remove('scale-popover--caret-left') + popover.classList.add('scale-popover--caret-right') + caretOnRight = true + } + + popover.style.top = `${top}px` + popover.style.left = `${left}px` + popover.style.right = 'auto' + + if (caret) { + let caretTop = rowCenterY - top - caretHeight / 2 + caretTop = Math.max(8, Math.min(caretTop, popoverH - caretHeight - 8)) + caret.style.top = `${caretTop}px` + } + + if (bridge) { + const bridgeHeight = Math.min(Math.max(rowRect.height - 4, 12), 32) + const bridgeTop = rowCenterY - top - bridgeHeight / 2 + let bridgeLeft + let bridgeWidth + + if (caretOnRight) { + bridgeLeft = popoverW + bridgeWidth = Math.max(0, gridRect.left - left - popoverW) + } else { + bridgeLeft = gridRect.right - left + bridgeWidth = Math.max(0, left - gridRect.right) + } + + bridge.style.top = `${bridgeTop}px` + bridge.style.left = `${bridgeLeft}px` + bridge.style.width = `${bridgeWidth}px` + bridge.style.height = `${bridgeHeight}px` + bridge.style.display = bridgeWidth > 2 ? 'block' : 'none' + } + + ensureScaleReadoutInView(gridRow.wrapper[0], popover) +} + +function ensureScaleReadoutInView(activeRowEl, popover) { + const pad = 16 + const rowRect = activeRowEl.getBoundingClientRect() + const popoverRect = popover.getBoundingClientRect() + + const minTop = Math.min(rowRect.top, popoverRect.top) + const maxBottom = Math.max(rowRect.bottom, popoverRect.bottom) + + if (minTop < pad) { + window.scrollBy({ top: minTop - pad, behavior: 'smooth' }) + } else if (maxBottom > window.innerHeight - pad) { + window.scrollBy({ top: maxBottom - window.innerHeight + pad, behavior: 'smooth' }) + } } async function use_scale(frm) { @@ -330,6 +522,11 @@ function convertWeight(value, fromUnit, toUom) { return `${converted.toFixed(3)} ${toUom}` } +function shouldAutoadvanceOnZero() { + const setting = scaleState.config && scaleState.config.autoadvance_on_zero + return setting !== 0 && setting !== false +} + function handleWorkflowState(parsed) { const isStable = parsed.status === 4 const zeroThreshold = scaleState.config.zero_threshold || 0.1 @@ -387,20 +584,22 @@ function handleWorkflowState(parsed) { break case 'WAITING_FOR_ZERO': - if (isZero) { - scaleState.workflowState = 'ZERO_DETECTED' - scaleState.lastStatusText = 'Ready for next' + if (isZero && isStable) { + if (shouldAutoadvanceOnZero()) { + advanceToNextRow() + } else { + scaleState.workflowState = 'ZERO_DETECTED' + scaleState.lastStatusText = 'Ready for next' + } + } else if (isZero) { + scaleState.lastStatusText = 'Stabilizing…' } else { scaleState.lastStatusText = 'Remove item' } break case 'ZERO_DETECTED': - if (scaleState.config.autoadvance_on_zero) { - advanceToNextRow() - } else { - scaleState.lastStatusText = 'Ready for next' - } + scaleState.lastStatusText = 'Ready for next' break } } @@ -437,19 +636,26 @@ function advanceToNextRow() { const itemsTableField = scaleState.config.items_table_field || 'items' const items = scaleState.frm.doc[itemsTableField] - if (!items) return + if (!items || items.length === 0) return - scaleState.activeRowIdx = (scaleState.activeRowIdx + 1) % items.length - - if (scaleState.activeRowIdx === 0) { - scaleState.lastStatusText = 'All items done' - } else { + const lastIdx = items.length - 1 + if (scaleState.activeRowIdx >= lastIdx) { scaleState.workflowState = 'WAITING' scaleState.stableReadingCount = 0 scaleState.qtyFilled = false - scaleState.lastStatusText = 'Place item on scale' + scaleState.lastStatusText = 'All items done — add a row for more' + updatePopover() + updateRowHighlight() + return } + scaleState.activeRowIdx += 1 + scaleState.workflowState = 'WAITING' + scaleState.stableReadingCount = 0 + scaleState.qtyFilled = false + scaleState.lastStatusText = 'Place item on scale' + + updatePopover() updateRowHighlight() } @@ -472,12 +678,7 @@ function updateRowHighlight() { }) if (scaleState.overlay && scaleState.activeRowIdx !== null && gridRows[scaleState.activeRowIdx]) { - const activeWrapper = gridRows[scaleState.activeRowIdx].wrapper - if (activeWrapper && activeWrapper.length) { - // rect.top is already in viewport coordinates for position:fixed - const rect = activeWrapper[0].getBoundingClientRect() - scaleState.overlay.style.top = `${Math.max(8, rect.top)}px` - } + positionScalePopover() } } @@ -486,4 +687,140 @@ frappe.dom.set_style(` background: var(--yellow-highlight-color) !important; outline: 2px solid var(--yellow-100) !important; } + + .scale-popover { + position: fixed; + width: 240px; + z-index: 2000; + transition: top 200ms ease, left 200ms ease; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + + .scale-popover-content { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + .scale-popover--caret-right .scale-popover-content { + border-right: 3px solid var(--yellow-100); + } + + .scale-popover--caret-left .scale-popover-content { + border-left: 3px solid var(--yellow-100); + } + + .scale-popover-bridge { + position: absolute; + display: none; + background: var(--yellow-highlight-color); + border-top: 2px solid var(--yellow-100); + border-bottom: 2px solid var(--yellow-100); + pointer-events: none; + z-index: 1; + } + + .scale-popover-caret { + position: absolute; + width: 20px; + height: 40px; + transition: top 200ms ease; + z-index: 2; + } + + .scale-popover--caret-right .scale-popover-caret { + right: -20px; + } + + .scale-popover--caret-right .scale-popover-caret::before { + content: ''; + position: absolute; + inset: 0; + background: var(--yellow-100); + clip-path: polygon(0 0, 0 100%, 100% 50%); + } + + .scale-popover--caret-right .scale-popover-caret::after { + content: ''; + position: absolute; + top: 2px; + bottom: 2px; + left: 0; + width: 16px; + background: var(--yellow-highlight-color); + clip-path: polygon(0 0, 0 100%, 100% 50%); + } + + .scale-popover--caret-left .scale-popover-caret { + left: -20px; + } + + .scale-popover--caret-left .scale-popover-caret::before { + content: ''; + position: absolute; + inset: 0; + background: var(--yellow-100); + clip-path: polygon(100% 0, 100% 100%, 0 50%); + } + + .scale-popover--caret-left .scale-popover-caret::after { + content: ''; + position: absolute; + top: 2px; + bottom: 2px; + right: 0; + width: 16px; + background: var(--yellow-highlight-color); + clip-path: polygon(100% 0, 100% 100%, 0 50%); + } + + .scale-popover-header { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 12px; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .scale-popover-readout { + text-align: center; + margin-bottom: 8px; + line-height: 1; + } + + .scale-readout-value { + font-size: 72px; + font-weight: bold; + color: var(--text-color); + font-family: Monaco, Courier, monospace; + white-space: nowrap; + } + + .scale-readout-unit { + font-size: 36px; + font-weight: bold; + color: var(--text-muted); + font-family: Monaco, Courier, monospace; + margin-left: 4px; + } + + .scale-readout-converted { + font-size: 36px; + font-weight: 600; + text-align: center; + margin-bottom: 12px; + color: var(--primary-color); + font-family: Monaco, Courier, monospace; + min-height: 1.2em; + } + + .scale-status-text { + font-size: 12px; + color: var(--text-muted); + text-align: center; + } `)