From fa88ef0afe8bfa040c0e551044e25ac48e797e38 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 20 Aug 2026 15:10:24 -0400 Subject: [PATCH 01/15] Device identity, collector enrollment file, and network device fixes --- .../computers/client/Report-AssetToShopDB.ps1 | 45 +++- plugins/computers/plugin.py | 173 ++++++++++++--- .../machines/frontend/views/MachinesList.vue | 14 +- .../frontend/views/NetworkDeviceDetail.vue | 28 ++- .../frontend/views/NetworkDeviceForm.vue | 11 +- tests/test_plugins/test_collector_deviceid.py | 207 ++++++++++++++++++ 6 files changed, 425 insertions(+), 53 deletions(-) create mode 100644 tests/test_plugins/test_collector_deviceid.py diff --git a/plugins/computers/client/Report-AssetToShopDB.ps1 b/plugins/computers/client/Report-AssetToShopDB.ps1 index d5f09d85..40b3079e 100644 --- a/plugins/computers/client/Report-AssetToShopDB.ps1 +++ b/plugins/computers/client/Report-AssetToShopDB.ps1 @@ -172,6 +172,7 @@ try { # 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3). # 3. C:\Enrollment\machine-number.txt - imaging value. # keyence / genspect / part-marker have no per-bay id -> no machineNo sent. +# Those bays name their DEVICE instead, in asset-id.txt below. $machineNo = '' foreach ($regPath in @( 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', @@ -236,6 +237,45 @@ try { Log "WARN could not read logged-in user: $($_.Exception.Message)" } +# The DEVICE hanging off this PC - the ONE enrollment file for every bay that +# has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and +# nothing else: it does not say what kind of device that is, because the pc-type +# already does, so the same file serves a part marker, a Keyence, a Genspect or +# anything added later without a new file or a client change. +# +# Deliberately NOT machine-number.txt. machinenumber answers "which bay is +# this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device +# there would silently stop every bay-gated manifest entry from matching. +# +# WHY A FILE AT ALL: every other identity the server can fall back to is derived +# from the PC - a prior link from this PC, or an asset number built from this +# hostname - so none of them survive a PC SWAP. The replacement box mints a +# SECOND record for the same physical device while the first keeps its history +# under a dead PC's name. This file is what survives. +# +# The server ADOPTS the named asset, refuses a value it cannot resolve, and +# refuses one that resolves to the wrong kind of thing - so a typo warns instead +# of inventing a phantom device. +$deviceId = '' +$idFile = 'C:\Enrollment\asset-id.txt' +if (Test-Path -LiteralPath $idFile) { + try { + $deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim() + if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" } + } catch { Log "WARN could not read ${idFile}: $($_.Exception.Message)" } +} +# 0.12.0 shipped measuringtool-id.txt before the file was generalised. Read it +# as a fallback so a bay already staged with one keeps reporting. +if (-not $deviceId) { + $mtFile = 'C:\Enrollment\measuringtool-id.txt' + if (Test-Path -LiteralPath $mtFile) { + try { + $deviceId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim() + if ($deviceId) { Log "deviceId from ${mtFile} (pre-0.13 name): $deviceId" } + } catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" } + } +} + # imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only # when present; absent -> server leaves existing pctype untouched (a bare report # never re-types a PC). Unmapped values return a warning, not an error. @@ -302,6 +342,7 @@ $body = @{ hostname = $hostname } if ($serialNumber) { $body['serialnumber'] = $serialNumber } if ($machineNo) { $body['machinenumber'] = $machineNo } if ($pcType) { $body['pctype'] = $pcType } +if ($deviceId) { $body['deviceid'] = $deviceId } if ($manufacturer) { $body['vendorname'] = $manufacturer } if ($model) { $body['modelnumber'] = $model } if ($osVersion) { $body['osname'] = $osVersion } @@ -312,8 +353,8 @@ $body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:s $json = $body | ConvertTo-Json -Compress -Depth 4 -Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f ` - $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp) +Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f ` + $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId) try { $resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json ` diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index f174b76c..4d55054a 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -106,12 +106,24 @@ class ComputersPlugin(BasePlugin): 'fields': { 'hostname': {'type': 'string', 'required': True}, 'machinenumber': {'type': 'string'}, - # The instrument this PC drives, named by the enrollment file - # measuringtool-id.txt. SEPARATE from machinenumber on purpose: - # "which bay is this" and "which instrument is this" are - # different facts, and machinenumber is what GE-Enforce - # TargetMachineNumbers gates on - repointing it at a tool would - # silently stop every bay-gated manifest entry matching. + # The DEVICE hanging off this PC, named by the enrollment file + # asset-id.txt. One file for every bay that has no NTLARS/eDNC + # MachineNo registry to identify it, and it does NOT say what + # kind of device it is: the pc-type already does, through + # SUBORDINATE_DEVICE_MAP. + # + # SEPARATE from machinenumber on purpose: "which bay is this" + # and "which device is this" are different facts, and + # machinenumber is what GE-Enforce TargetMachineNumbers gates + # on - repointing it at a device would silently stop every + # bay-gated manifest entry matching. + # + # Named deviceid rather than assetid because assetid is this + # contract's own RESPONSE field for a PC's integer primary key. + 'deviceid': {'type': 'string'}, + # Shipped in 0.12.0, before the file was generalised. Accepted + # as an alias so a bay staged with measuringtool-id.txt keeps + # reporting; deviceid wins when both arrive. 'measuringtoolid': {'type': 'string'}, 'pctype': {'type': 'string'}, 'pcsubtype': {'type': 'string'}, @@ -394,8 +406,14 @@ class ComputersPlugin(BasePlugin): # marker PC must not also claim the operation directly: several markers # serve one operation number, so direct claims would fight over it. The # marker is partof the operation and control propagates along that rail. + # asset-id.txt, falling back to the 0.12.0 field name. One value feeds + # BOTH device paths: the file names the device and nothing else, so + # which sync consumes it follows from the pc-type. + deviceid = (payload.get('deviceid') + or payload.get('measuringtoolid') or '').strip() or None + partmarkers = self._sync_partmarker(comp, pctype, machinenumber, - warnings) + warnings, deviceid=deviceid) # PC -> machine link from the reported machine number. if partmarkers: @@ -410,7 +428,7 @@ class ComputersPlugin(BasePlugin): # get an attached MeasuringTool asset auto-created and linked. measuringtoollinks = self._sync_measuringtool_link( comp.asset, pctype, hostname, warnings, - measuringtoolid=payload.get('measuringtoolid'), + measuringtoolid=deviceid, machinenumber=machinenumber) db.session.commit() @@ -750,7 +768,70 @@ class ComputersPlugin(BasePlugin): 'machinenumber': machine.assetnumber, 'superseded': len(held)}] - def _sync_partmarker(self, comp, pctype, machinenumber, warnings): + def _is_device_of_type(self, asset, spec): + """Is this asset the kind of device the pc-type's spec describes? + + asset-id.txt names a device and says nothing about its type, so the + type is what proves the file points somewhere sensible. A measuring + tool is checked against the measuring-tool set; anything else is + checked against its machine type, which is where a Part Marker lives. + """ + if asset is None: + return False + if spec.get('assettype') == 'measuring_tool': + return asset.assetid in self._measuringtool_assetids() + try: + from plugins.machines.models import Machine, MachineType + except ImportError: + return False + row = (db.session.query(MachineType.machinetype) + .join(Machine, Machine.machinetypeid == MachineType.machinetypeid) + .filter(Machine.assetid == asset.assetid).first()) + return bool(row) and row[0] == spec['typename'] + + def _ensure_device_rows(self, deviceasset, spec, pcasset, controls, label, + warnings): + """Get-or-create the extension row and the PC -> device control link. + + Shared by the named-device and minted paths: an adopted asset may have + no extension row, and the control link may already exist under another + label, so both are get-or-create rather than insert. + """ + from shopdb.api import AssetRelationship + try: + from plugins.machines.models import Machine, MachineType + except ImportError: + warnings.append('machines plugin unavailable; {} device skipped' + .format(spec['typename'])) + return + if not Machine.query.filter_by(assetid=deviceasset.assetid).first(): + devicetype = MachineType.query.filter_by( + machinetype=spec['typename']).first() + if not devicetype: + devicetype = MachineType(machinetype=spec['typename'], + description=spec.get('description')) + db.session.add(devicetype) + db.session.flush() + db.session.add(Machine(assetid=deviceasset.assetid, + machinetypeid=devicetype.machinetypeid)) + controlrow = AssetRelationship.query.filter_by( + sourceassetid=pcasset.assetid, + targetassetid=deviceasset.assetid, + relationshiptypeid=controls.relationshiptypeid).first() + if controlrow is not None: + controlrow.isactive = True + if not controlrow.label: + controlrow.label = label + else: + db.session.add(AssetRelationship( + sourceassetid=pcasset.assetid, + targetassetid=deviceasset.assetid, + relationshiptypeid=controls.relationshiptypeid, + label=label, + isactive=True)) + + def _sync_partmarker(self, comp, pctype, machinenumber, warnings, + deviceid=None): """Give a part-marker PC a marker asset of its own, under its operation. Several Telesis markers serve one operation number - 0613, 0615 and @@ -760,10 +841,19 @@ class ComputersPlugin(BasePlugin): question about an individual marker (how many are there, which port, which one failed) could be asked at all. - One marker per PC, so the PC identifies the marker and the collector can - mint it the same way it already mints a CMM or a Keyence unit for a - metrology PC. The marker is a machine asset of type Part Marker, the PC - `controls` it, and the marker is `partof` the operation it serves. + One marker per PC. The marker is a machine asset of type Part Marker, + the PC `controls` it, and the marker is `partof` the operation it + serves. + + IDENTITY COMES FROM asset-id.txt FIRST, because everything else here is + derived from the PC and a PC is not permanent. Reuse looks for a prior + link from THIS PC asset and adoption looks up `-PARTMARKER`, + so both survive a re-image and neither survives a PC SWAP: a new + hostname is a new PC asset with no prior link and a predicted number + that has never existed, so the same physical Telesis unit gets a second + record while the first keeps its config and backup history under a dead + PC's name. That is the failure the metrology path already learned from, + where minting from the hostname left 43 instruments shadowed by twins. That last rail is why the PC does not also claim the operation directly: `controls` propagates through `partof` (seeded in reference-data), so @@ -801,8 +891,26 @@ class ComputersPlugin(BasePlugin): label = spec['label'] - # Reuse this PC's existing device before minting one, so a re-image - # never leaves a second device behind for the same physical unit. + # --- 1. an explicitly named device wins over everything ------------- + named = (deviceid or '').strip() + namedasset = None + if named: + candidate = self._asset_by_number(named) + if candidate is None: + # Warn rather than invent: a typo must not mint a phantom + # device that nobody can account for. + warnings.append( + 'no asset for device {!r}; not linked'.format(named)) + elif not self._is_device_of_type(candidate, spec): + # The name resolved, but not to this pc-type's device. Refuse + # and say which asset it hit, rather than filing an unrelated + # asset under a device label the collector also owns. + warnings.append( + 'asset {!r} is not a {}; not linked'.format( + candidate.assetnumber, spec['typename'])) + else: + namedasset = candidate + existing = AssetRelationship.query.filter( AssetRelationship.sourceassetid == pcasset.assetid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid, @@ -811,7 +919,18 @@ class ComputersPlugin(BasePlugin): reuse = next((rel for rel in existing if rel.isactive), None) \ or (existing[0] if existing else None) - if reuse: + if namedasset is not None: + # The file is authoritative. A PC that previously minted its own + # marker and is now told the real one keeps only the named link; + # the stale link is archived by the one-marker-per-PC sweep below. + markerasset = namedasset + reuse = next((rel for rel in existing + if rel.targetassetid == namedasset.assetid), None) + if reuse is not None: + reuse.isactive = True + self._ensure_device_rows(markerasset, spec, pcasset, controls, + label, warnings) + elif reuse: reuse.isactive = True markerasset = db.session.get(Asset, reuse.targetassetid) else: @@ -852,26 +971,8 @@ class ComputersPlugin(BasePlugin): db.session.add(markerasset) db.session.flush() - # The extension row may be missing on an adopted asset, and the link - # may already exist under another label - both get-or-create. - if not Machine.query.filter_by(assetid=markerasset.assetid).first(): - db.session.add(Machine(assetid=markerasset.assetid, - machinetypeid=devicetype.machinetypeid)) - controlrow = AssetRelationship.query.filter_by( - sourceassetid=pcasset.assetid, - targetassetid=markerasset.assetid, - relationshiptypeid=controls.relationshiptypeid).first() - if controlrow is not None: - controlrow.isactive = True - if not controlrow.label: - controlrow.label = label - else: - db.session.add(AssetRelationship( - sourceassetid=pcasset.assetid, - targetassetid=markerasset.assetid, - relationshiptypeid=controls.relationshiptypeid, - label=label, - isactive=True)) + self._ensure_device_rows(markerasset, spec, pcasset, controls, + label, warnings) # One marker per PC: archive any other collector marker link. for rel in existing: diff --git a/plugins/machines/frontend/views/MachinesList.vue b/plugins/machines/frontend/views/MachinesList.vue index b02123e2..19732aab 100644 --- a/plugins/machines/frontend/views/MachinesList.vue +++ b/plugins/machines/frontend/views/MachinesList.vue @@ -36,7 +36,9 @@ - + {{ item.assetnumber }} @@ -58,12 +60,20 @@ {{ item.locationname || '-' }} + View + - diff --git a/plugins/network/frontend/views/NetworkDeviceDetail.vue b/plugins/network/frontend/views/NetworkDeviceDetail.vue index 3e984f10..6921b403 100644 --- a/plugins/network/frontend/views/NetworkDeviceDetail.vue +++ b/plugins/network/frontend/views/NetworkDeviceDetail.vue @@ -33,22 +33,26 @@ {{ heroWarranty.label }} +
-
- Asset # - {{ device.assetnumber }} +
+ Asset # + {{ device.assetnumber }}
-
- Serial - {{ device.serialnumber }} +
+ Serial + {{ device.serialnumber }}
-
- Location - {{ device.locationname }} +
+ Location + {{ device.locationname }}
-
- Business Unit - {{ device.businessunitname }} +
+ Business Unit + {{ device.businessunitname }}
diff --git a/plugins/network/frontend/views/NetworkDeviceForm.vue b/plugins/network/frontend/views/NetworkDeviceForm.vue index c53b7a9c..526e9d99 100644 --- a/plugins/network/frontend/views/NetworkDeviceForm.vue +++ b/plugins/network/frontend/views/NetworkDeviceForm.vue @@ -161,7 +161,7 @@ @@ -395,6 +395,15 @@ const generatedAssetNumber = computed(() => { }) const vendors = ref([]) const models = ref([]) + +// Models belong to a vendor, so picking one narrows the list - the same +// behaviour PCForm, MachineForm and PrinterForm have. With no vendor chosen the +// whole catalogue shows, rather than an empty dropdown that reads as "no models +// exist" when it means "pick a vendor first". +const filteredModels = computed(() => { + if (!form.value.vendorid) return models.value + return models.value.filter(m => m.vendorid === form.value.vendorid) +}) const locations = ref([]) const statuses = ref([]) const businessUnits = ref([]) diff --git a/tests/test_plugins/test_collector_deviceid.py b/tests/test_plugins/test_collector_deviceid.py new file mode 100644 index 00000000..5a721afd --- /dev/null +++ b/tests/test_plugins/test_collector_deviceid.py @@ -0,0 +1,207 @@ +"""asset-id.txt names the device, and that name survives a PC swap. + +Every other identity the collector has for a subordinate device is derived from +the PC: reuse looks for a prior link from THIS PC asset, and adoption looks up +`-`. Both survive a re-image and neither survives a swap - a +new hostname is a new PC asset with no prior link and a predicted number that +has never existed, so the same physical device gets a second record while the +first keeps its history under a dead PC's name. + +That is the failure that left 43 measuring tools shadowed by minted twins. These +tests pin the fix for BOTH device families, because the part-marker path was +modelled on the metrology path as it stood before it was fixed. +""" + +import pytest + +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.relationship import RelationshipType, AssetRelationship + +KEY = 'deviceid-key' + + +@pytest.fixture +def collector_key(app): + old = app.config.get('COLLECTOR_API_KEY') + app.config['COLLECTOR_API_KEY'] = KEY + yield KEY + app.config['COLLECTOR_API_KEY'] = old + + +@pytest.fixture +def rig(db): + for name in ('computer', 'machine', 'measuring_tool'): + if not AssetType.query.filter_by(assettype=name).first(): + db.session.add(AssetType(assettype=name)) + for name in ('controls', 'partof'): + if not RelationshipType.query.filter_by(relationshiptype=name).first(): + db.session.add(RelationshipType(relationshiptype=name)) + db.session.commit() + + +def _report(client, key, hostname, pctype, **extra): + payload = {'hostname': hostname, 'pctype': pctype} + payload.update(extra) + return client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': key}) + + +def _asset(db, assetnumber, assettype='machine'): + """A bare asset: an operation, or something that is NOT a device.""" + at = AssetType.query.filter_by(assettype=assettype).first() + asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid) + db.session.add(asset) + db.session.commit() + return asset + + +def _marker(db, assetnumber): + """A Part Marker the collector did NOT create - the real unit on the floor. + + The extension row and machine type are what make it a marker; a bare asset + of the right number is deliberately refused, which the wrong-type test pins. + """ + from plugins.machines.models import Machine, MachineType + at = AssetType.query.filter_by(assettype='machine').first() + mtype = MachineType.query.filter_by(machinetype='Part Marker').first() + if mtype is None: + mtype = MachineType(machinetype='Part Marker') + db.session.add(mtype) + db.session.flush() + asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid) + db.session.add(asset) + db.session.flush() + db.session.add(Machine(assetid=asset.assetid, + machinetypeid=mtype.machinetypeid)) + db.session.commit() + return asset + + +def _tool(db, assetnumber): + """A measuring tool the collector did NOT create.""" + from plugins.measuringtools.models import MeasuringTool + at = AssetType.query.filter_by(assettype='measuring_tool').first() + asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid) + db.session.add(asset) + db.session.flush() + db.session.add(MeasuringTool(assetid=asset.assetid)) + db.session.commit() + return asset + + +def _controlled(pcname, label): + """Asset numbers this PC controls under a collector label.""" + pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first() + if pc is None: + return [] + rels = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, label=label, isactive=True).all() + return sorted(Asset.query.filter_by(assetid=r.targetassetid).first().assetnumber + for r in rels) + + +# --------------------------------------------------------------- part markers + +def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key): + """THE case this exists for. Same physical marker, two different PCs.""" + _asset(db, '0613') + marker = _marker(db, 'PM-0613-A') + + first = _report(client, collector_key, 'FMARK100', + pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + assert first.status_code in (200, 201), first.get_data(as_text=True)[:300] + + # The bay's PC is replaced. New hostname, same marker named in asset-id.txt. + second = _report(client, collector_key, 'FMARK200', + pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + assert second.status_code in (200, 201), second.get_data(as_text=True)[:300] + + assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None + assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None + assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A'] + assert Asset.query.filter_by(assetnumber='PM-0613-A').count() == 1 + assert marker.assetid == Asset.query.filter_by( + assetnumber='PM-0613-A').first().assetid + + +def test_without_the_file_a_swap_still_mints_the_old_way(client, db, rig, + collector_key): + """The unfixed behaviour, pinned so the file's value stays visible.""" + _asset(db, '0614') + assert _report(client, collector_key, 'FMARK300', + pctype='gea-shopfloor-partmarker', + machinenumber='0614').status_code in (200, 201) + assert _report(client, collector_key, 'FMARK400', + pctype='gea-shopfloor-partmarker', + machinenumber='0614').status_code in (200, 201) + assert Asset.query.filter_by(assetnumber='FMARK300-PARTMARKER').first() + assert Asset.query.filter_by(assetnumber='FMARK400-PARTMARKER').first() + + +def test_an_unknown_device_warns_and_links_nothing(client, db, rig, + collector_key): + _asset(db, '0616') + resp = _report(client, collector_key, 'FMARK500', + pctype='gea-shopfloor-partmarker', + machinenumber='0616', deviceid='PM-TYPO') + assert resp.status_code in (200, 201) + assert Asset.query.filter_by(assetnumber='PM-TYPO').first() is None + warnings = ' '.join(resp.get_json()['data'].get('warnings', [])) + assert 'PM-TYPO' in warnings + + +def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key): + """A machine number pasted into asset-id.txt must not become a marker.""" + _asset(db, '0617') + _asset(db, 'PLAIN-MACHINE') + resp = _report(client, collector_key, 'FMARK600', + pctype='gea-shopfloor-partmarker', + machinenumber='0617', deviceid='PLAIN-MACHINE') + assert resp.status_code in (200, 201) + assert _controlled('FMARK600', 'collector:partmarker') != ['PLAIN-MACHINE'] + warnings = ' '.join(resp.get_json()['data'].get('warnings', [])) + assert 'PLAIN-MACHINE' in warnings + + +def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key): + _asset(db, '0618') + _marker(db, 'PM-0618-A') + codes = [_report(client, collector_key, 'FMARK700', + pctype='gea-shopfloor-partmarker', + machinenumber='0618', deviceid='PM-0618-A').status_code + for _ in range(3)] + assert codes == [codes[0]] * 3, codes + assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A'] + + +# ------------------------------------------------------------ measuring tools + +def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key): + """One file, no device type in it: the pc-type decides which sync uses it.""" + _tool(db, 'MT-9001') + resp = _report(client, collector_key, 'KEYENCE100', + pctype='gea-shopfloor-keyence', deviceid='MT-9001') + assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300] + assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001'] + assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None + + +def test_the_0120_field_name_still_works(client, db, rig, collector_key): + """measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting.""" + _tool(db, 'MT-9002') + resp = _report(client, collector_key, 'KEYENCE200', + pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002') + assert resp.status_code in (200, 201) + assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002'] + + +def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key): + _tool(db, 'MT-9003') + _tool(db, 'MT-9004') + resp = _report(client, collector_key, 'KEYENCE300', + pctype='gea-shopfloor-keyence', + deviceid='MT-9003', measuringtoolid='MT-9004') + assert resp.status_code in (200, 201) + assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003'] From ebb86ccf3b2e947ba85a5d259ef7c0e8b1368abd Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 20 Aug 2026 17:09:02 -0400 Subject: [PATCH 02/15] Blank asset numbers, contested device claims, and honest toner forecasting --- plugins/computers/api/routes.py | 11 ++ plugins/computers/plugin.py | 103 +++++++++++-- plugins/machines/api/routes.py | 11 ++ .../machines/frontend/views/MachineForm.vue | 33 +++- plugins/measuringtools/api/routes.py | 11 ++ plugins/network/api/routes.py | 11 ++ plugins/printers/api/asset_routes.py | 11 ++ .../printers/frontend/views/TonerForecast.vue | 21 ++- plugins/printers/services/supply_history.py | 144 ++++++++++++++++-- shopdb/core/api/assets.py | 11 ++ tests/test_plugins/test_collector_deviceid.py | 68 ++++++++- tests/test_plugins/test_supply_history.py | 72 +++++++++ 12 files changed, 473 insertions(+), 34 deletions(-) diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index fc9f26a4..94e3b828 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -639,6 +639,17 @@ def update_computer(computer_id: int): asset = comp.asset + # An UPDATE may not blank the asset number. Create validates it and the + # column is NOT NULL, but the conflict check below only runs when the value + # DIFFERS, and '' never collides with anything - so a payload carrying an + # empty assetnumber wrote it straight through. A form that loaded blank + # (a failed reference load, a partial fetch) then saved the blank over a + # real record. + if 'assetnumber' in data and not (data['assetnumber'] or '').strip(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'assetnumber cannot be empty') + # Check for conflicting assetnumber if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 4d55054a..e826c422 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -790,7 +790,7 @@ class ComputersPlugin(BasePlugin): return bool(row) and row[0] == spec['typename'] def _ensure_device_rows(self, deviceasset, spec, pcasset, controls, label, - warnings): + warnings, active=True): """Get-or-create the extension row and the PC -> device control link. Shared by the named-device and minted paths: an adopted asset may have @@ -819,7 +819,7 @@ class ComputersPlugin(BasePlugin): targetassetid=deviceasset.assetid, relationshiptypeid=controls.relationshiptypeid).first() if controlrow is not None: - controlrow.isactive = True + controlrow.isactive = active if not controlrow.label: controlrow.label = label else: @@ -828,7 +828,58 @@ class ComputersPlugin(BasePlugin): targetassetid=deviceasset.assetid, relationshiptypeid=controls.relationshiptypeid, label=label, - isactive=True)) + isactive=active)) + + def _device_incumbents_yield(self, deviceasset, pcasset, label, warnings): + """Settle a device already controlled by a DIFFERENT PC. + + A physical instrument or marker hangs off one PC. Before this, both + paths only ever looked at links whose source was THIS PC, so a bay + naming a device another PC still holds produced two active `controls` + rows with no warning - the replaced PC kept its link forever, and a + file copied to a second bay claimed the device silently. + + The machine link settled this years ago and this reuses its rule rather + than inventing a second one: an incumbent that has gone quiet past the + claim window, or been moved off In Use, has yielded, and its link is + ARCHIVED (never deleted, so "which PC drove this in June" stays + answerable). An incumbent still alive keeps the device, and the + challenger is recorded dormant instead of contesting a link that can + only have one holder. + + Returns True when this PC may hold the device actively. + """ + from shopdb.api import AssetRelationship, RelationshipType, Asset + + controls = RelationshipType.query.filter_by( + relationshiptype='controls').first() + if not controls or not deviceasset or not pcasset: + return True + + others = AssetRelationship.query.filter( + AssetRelationship.targetassetid == deviceasset.assetid, + AssetRelationship.relationshiptypeid == controls.relationshiptypeid, + AssetRelationship.label == label, + AssetRelationship.isactive.is_(True), + AssetRelationship.sourceassetid != pcasset.assetid, + ).all() + if not others: + return True + + holding = [] + for rel in others: + if self._incumbent_has_yielded(rel.sourceassetid): + rel.isactive = False + else: + holder = db.session.get(Asset, rel.sourceassetid) + holding.append(holder.assetnumber if holder else rel.sourceassetid) + + if holding: + warnings.append( + '{} is still controlled by {}; recorded but not activated' + .format(deviceasset.assetnumber, ', '.join(str(h) for h in holding))) + return False + return True def _sync_partmarker(self, comp, pctype, machinenumber, warnings, deviceid=None): @@ -892,6 +943,16 @@ class ComputersPlugin(BasePlugin): label = spec['label'] # --- 1. an explicitly named device wins over everything ------------- + # + # NAMING A DEVICE IS A COMMITMENT. If the bay says which device it + # drives and that name does not resolve, the answer is to link nothing + # and say so - NOT to fall through and mint -PARTMARKER, which is + # the hostname-derived twin this whole path exists to prevent. The + # warning used to be true about the typo'd number and false about the + # twin: it warned, then minted anyway. + # + # Silence still means "work it out", so a bay with no file keeps the + # reuse-then-mint behaviour it has always had. named = (deviceid or '').strip() namedasset = None if named: @@ -901,15 +962,16 @@ class ComputersPlugin(BasePlugin): # device that nobody can account for. warnings.append( 'no asset for device {!r}; not linked'.format(named)) - elif not self._is_device_of_type(candidate, spec): + return [] + if not self._is_device_of_type(candidate, spec): # The name resolved, but not to this pc-type's device. Refuse # and say which asset it hit, rather than filing an unrelated # asset under a device label the collector also owns. warnings.append( 'asset {!r} is not a {}; not linked'.format( candidate.assetnumber, spec['typename'])) - else: - namedasset = candidate + return [] + namedasset = candidate existing = AssetRelationship.query.filter( AssetRelationship.sourceassetid == pcasset.assetid, @@ -920,6 +982,11 @@ class ComputersPlugin(BasePlugin): or (existing[0] if existing else None) if namedasset is not None: + # The file is authoritative FOR THIS PC, but the device may still be + # held by another one. Settle that first: a yielded incumbent is + # archived, a live one keeps it and this link is recorded dormant. + mayhold = self._device_incumbents_yield( + namedasset, pcasset, label, warnings) # The file is authoritative. A PC that previously minted its own # marker and is now told the real one keeps only the named link; # the stale link is archived by the one-marker-per-PC sweep below. @@ -929,7 +996,7 @@ class ComputersPlugin(BasePlugin): if reuse is not None: reuse.isactive = True self._ensure_device_rows(markerasset, spec, pcasset, controls, - label, warnings) + label, warnings, active=mayhold) elif reuse: reuse.isactive = True markerasset = db.session.get(Asset, reuse.targetassetid) @@ -1280,10 +1347,14 @@ class ComputersPlugin(BasePlugin): candidate = self._asset_by_number(named) if candidate is None: # Warn rather than invent: a typo in the file must not mint a - # phantom instrument that nobody can account for. + # phantom instrument that nobody can account for. RETURN, do + # not fall through - minting -CMM here would create the + # hostname-derived twin this resolution order exists to avoid, + # while the warning claimed nothing was linked. warnings.append( 'no asset for measuring tool {!r}; not linked'.format(named)) - elif candidate.assetid not in self._measuringtool_assetids(): + return [] + if candidate.assetid not in self._measuringtool_assetids(): # The name resolved, but not to an instrument. measuringtool-id # .txt holding a machine number would otherwise link the PC to # that MACHINE under a measuring-tool label - a link that reads @@ -1292,8 +1363,8 @@ class ComputersPlugin(BasePlugin): warnings.append( 'asset {!r} is not a measuring tool; not linked'.format( candidate.assetnumber)) - else: - adopted = candidate + return [] + adopted = candidate # --- 2. a prior collector link (reactivate + retype) ----------------- reuse = next((rel for rel in existing if rel.isactive), None) \ @@ -1307,6 +1378,11 @@ class ComputersPlugin(BasePlugin): pcasset, controls, machinenumber) if adopted is not None: + # Settle any OTHER PC still holding this instrument first: a yielded + # incumbent is archived, a live one keeps it and this link is + # recorded dormant rather than becoming a second active holder. + mayhold = self._device_incumbents_yield( + adopted, pcasset, MEASURINGTOOL_LINK_ORIGIN, warnings) # CLAIM the existing link rather than adding a second one. A row for # (pc, tool, controls) usually already exists - that is how the tool # was found - and assetrelationships is unique on exactly that @@ -1330,13 +1406,14 @@ class ComputersPlugin(BasePlugin): # is ours from here, so a later cycle reuses it instead of # minting. claimed.label = MEASURINGTOOL_LINK_ORIGIN - claimed.isactive = True + claimed.isactive = mayhold else: db.session.add(AssetRelationship( sourceassetid=pcasset.assetid, targetassetid=adopted.assetid, relationshiptypeid=controls.relationshiptypeid, - label=MEASURINGTOOL_LINK_ORIGIN)) + label=MEASURINGTOOL_LINK_ORIGIN, + isactive=mayhold)) if adopted.measuringtool and tooltype: adopted.measuringtool.measuringtooltypeid = \ tooltype.measuringtooltypeid diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py index b6af6721..7667add1 100644 --- a/plugins/machines/api/routes.py +++ b/plugins/machines/api/routes.py @@ -437,6 +437,17 @@ def update_machine(machine_id: int): asset = mach.asset + # An UPDATE may not blank the asset number. Create validates it and the + # column is NOT NULL, but the conflict check below only runs when the value + # DIFFERS, and '' never collides with anything - so a payload carrying an + # empty assetnumber wrote it straight through. A form that loaded blank + # (a failed reference load, a partial fetch) then saved the blank over a + # real record. + if 'assetnumber' in data and not (data['assetnumber'] or '').strip(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'assetnumber cannot be empty') + # Check for conflicting assetnumber if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): diff --git a/plugins/machines/frontend/views/MachineForm.vue b/plugins/machines/frontend/views/MachineForm.vue index 1b396df8..efe0846c 100644 --- a/plugins/machines/frontend/views/MachineForm.vue +++ b/plugins/machines/frontend/views/MachineForm.vue @@ -7,6 +7,11 @@
Loading...
+ +
{{ error }}
+

Identity

@@ -425,6 +430,10 @@ const statuses = ref([]) const vendors = ref([]) const locations = ref([]) const models = ref([]) +// Set when the record itself could not be loaded, as opposed to a reference +// list failing. The form is withheld entirely, because an empty edit form is +// indistinguishable from a record whose fields are genuinely empty. +const loadFailed = ref(false) const businessunits = ref([]) const pcs = ref([]) const relationshipTypes = ref([]) @@ -506,9 +515,29 @@ onMounted(async () => { relationshipTypeId.value = controlsType.relationshiptypeid } - // Load machine if editing + // Load machine if editing. + // + // ITS OWN try/catch, deliberately. This used to sit inside the same block + // as the eight reference loads above, so ONE transient failure among them - + // a page of listAll() timing out on a busy collector cycle - rejected the + // whole thing and rendered a fully editable EDIT form with every field + // BLANK, including the required Asset Number, next to a Save button. Typing + // a number and saving then wrote the blank-loaded values over a real + // machine. The reference lists degrade to empty dropdowns; the record must + // not degrade at all. if (isEdit.value) { - const response = await machinesApi.get(route.params.id) + let response + try { + response = await machinesApi.get(route.params.id) + } catch (err) { + console.error('Error loading machine:', err) + error.value = 'Could not load this machine. Nothing has been changed - ' + + 'reload the page rather than saving, or the blank form would ' + + 'overwrite the record.' + loadFailed.value = true + loading.value = false + return + } const data = response.data.data currentAssetId.value = data.assetid || null currentMachine.value = data diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py index b0384454..9ee2748d 100644 --- a/plugins/measuringtools/api/routes.py +++ b/plugins/measuringtools/api/routes.py @@ -316,6 +316,17 @@ def update_tool(tool_id: int): data = request.get_json() or {} asset = tool.asset + # An UPDATE may not blank the asset number. Create validates it and the + # column is NOT NULL, but the conflict check below only runs when the value + # DIFFERS, and '' never collides with anything - so a payload carrying an + # empty assetnumber wrote it straight through. A form that loaded blank + # (a failed reference load, a partial fetch) then saved the blank over a + # real record. + if 'assetnumber' in data and not (data['assetnumber'] or '').strip(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'assetnumber cannot be empty') + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): return error_response(ErrorCodes.CONFLICT, diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 4c7780b5..f8e1f93d 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -483,6 +483,17 @@ def update_network_device(device_id: int): asset = netdev.asset + # An UPDATE may not blank the asset number. Create validates it and the + # column is NOT NULL, but the conflict check below only runs when the value + # DIFFERS, and '' never collides with anything - so a payload carrying an + # empty assetnumber wrote it straight through. A form that loaded blank + # (a failed reference load, a partial fetch) then saved the blank over a + # real record. + if 'assetnumber' in data and not (data['assetnumber'] or '').strip(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'assetnumber cannot be empty') + # Check for conflicting assetnumber if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 158ffe82..574c0d43 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -1963,6 +1963,17 @@ def update_printer(printer_id: int): asset = printer.asset + # An UPDATE may not blank the asset number. Create validates it and the + # column is NOT NULL, but the conflict check below only runs when the value + # DIFFERS, and '' never collides with anything - so a payload carrying an + # empty assetnumber wrote it straight through. A form that loaded blank + # (a failed reference load, a partial fetch) then saved the blank over a + # real record. + if 'assetnumber' in data and not (data['assetnumber'] or '').strip(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'assetnumber cannot be empty') + # Check for conflicting assetnumber if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): diff --git a/plugins/printers/frontend/views/TonerForecast.vue b/plugins/printers/frontend/views/TonerForecast.vue index f9b4e2e0..98841259 100644 --- a/plugins/printers/frontend/views/TonerForecast.vue +++ b/plugins/printers/frontend/views/TonerForecast.vue @@ -90,7 +90,7 @@ Level Runs out Rate - Changed + Replacements @@ -123,11 +123,20 @@ {{ daysText(c) }} - {{ c.burnrateperday != null ? c.burnrateperday + '%/day' : '-' }} + + - {{ c.replacements || 0 }} - / {{ c.basisdays }}d + + + @@ -424,6 +433,10 @@ onMounted(load) .days.empty { color: var(--danger); } .days.soon { color: var(--warning); } .small { font-size: 0.8rem; } + +/* Marks a rate the intervals do not agree on. Deliberately quiet - it qualifies + the number beside it rather than competing with the urgency bands. */ +.unstable { margin-left: 2px; font-weight: 600; cursor: help; } .empty-state { padding: 2rem; text-align: center; } .footnote { margin-top: 1rem; font-size: 0.85rem; } diff --git a/plugins/printers/services/supply_history.py b/plugins/printers/services/supply_history.py index 9917420e..e3e6bda3 100644 --- a/plugins/printers/services/supply_history.py +++ b/plugins/printers/services/supply_history.py @@ -20,6 +20,19 @@ from datetime import datetime, timezone # new cartridge rather than noise. REPLACEMENT_RISE = 10 +# A replacement must also LAND high. A fresh cartridge reads near full, so a +# rise that stops mid-range is a gauge bouncing, not a swap. Without this the +# count was any +10 between readings, and the two common noise shapes both +# scored: a supply reading 0 or near-0 while it was out of the machine, then the +# real level again (0 -> 60 counts as +60), and a coarse gauge ticking back up +# after a reseat or a power cycle. That is how one cartridge claimed five +# changes in ninety days. +# +# A site that fits PART-USED cartridges will under-count with this rule. That is +# the right way round: a missed swap widens the run and slows the estimate, +# while a phantom swap resets the run and throws the estimate away entirely. +NEW_CARTRIDGE_LEVEL = 80 + # Below this many readings a slope is arithmetic, not evidence. Two points # through a coarse gauge can "prove" any rate at all. MIN_POINTS_FOR_ESTIMATE = 4 @@ -29,6 +42,12 @@ MIN_POINTS_FOR_ESTIMATE = 4 # says "never", which is worse than saying nothing. MIN_DROP_FOR_ESTIMATE = 2 +# How far the fastest and slowest intervals may differ before a single rate +# stops being a fair summary. A cartridge that ran at 10 percent/day for two +# days and 0.2 percent/day since is not described by any one number, and a +# precise-looking figure invites more trust than it has earned. +RATE_SPREAD_FACTOR = 5 + # At or below this, the cartridge is done and the arithmetic stops being the # useful answer. A supply sitting at 1% that drains a tenth of a point a day # computes to ten days; a printer at 1% is out of toner as far as anyone @@ -58,18 +77,65 @@ def normalise(points): continue out.append((datetime.fromtimestamp(seconds, tz=timezone.utc), level)) out.sort(key=lambda p: p[0]) + return drop_spikes(out) + + +def drop_spikes(points, rise=REPLACEMENT_RISE): + """Remove one-reading dips that RECOVER to where they came from. + + A single reading far below both neighbours, then a recovery, is a big + upward step that scores as a cartridge change. That is how a cartridge + claimed five changes in ninety days. + + NOT a Zabbix timeout: an item that does not answer records nothing, it does + not write a zero. The dip is a value the device really reported - a supply + pulled out to be shaken and reseated, a door open mid-poll, or a site whose + preprocessing maps the Printer MIB's "unknown" sentinels (-1/-2/-3, which + cannot land in the unsigned history table) onto 0. + + The filter keys on SHAPE rather than cause, which is why it holds for all of + them: a level that comes back to where it was did not get a new cartridge. + + Only a dip that comes BACK to roughly its previous level is removed. A + genuine near-empty reading before a swap (30, 5, 100) does not recover - it + jumps to full - so it is kept, and the swap after it still counts. + """ + if len(points) < 3: + return points + out = [points[0]] + for index in range(1, len(points) - 1): + previous = points[index - 1][1] + current = points[index][1] + following = points[index + 1][1] + dipped = (previous - current) >= rise and (following - current) >= rise + recovered = abs(following - previous) <= rise + if dipped and recovered: + continue + out.append(points[index]) + out.append(points[-1]) return out -def find_replacements(points, rise=REPLACEMENT_RISE): +def _is_replacement(previous, current, rise=REPLACEMENT_RISE, + newlevel=NEW_CARTRIDGE_LEVEL): + """One definition of "a cartridge was changed", used by every caller. + + The rise must be big enough to clear gauge noise AND land near full, which + is what a new cartridge reads. Both conditions, because either alone admits + a shape that is not a swap. + """ + return (current - previous) >= rise and current >= newlevel + + +def find_replacements(points, rise=REPLACEMENT_RISE, + newlevel=NEW_CARTRIDGE_LEVEL): """Timestamps where the level jumped up - one per cartridge change. - Returns [] for a series that only falls. A rise smaller than `rise` is - treated as noise, not a replacement. + Returns [] for a series that only falls. """ replacements = [] for (_, previous), (when, current) in zip(points, points[1:]): - if current - previous >= rise: + if _is_replacement(previous, current, rise, newlevel): replacements.append(when) return replacements @@ -84,28 +150,78 @@ def current_run(points, rise=REPLACEMENT_RISE): return [] start = 0 for index in range(1, len(points)): - if points[index][1] - points[index - 1][1] >= rise: + if _is_replacement(points[index - 1][1], points[index][1], rise): start = index return points[start:] +def interval_rates(points): + """Percent-per-day for each consecutive pair, falling intervals only. + + A rise inside a run is gauge noise (a swap would have ended the run), and + a flat interval is real information - a cartridge that did not move - so it + stays in at zero. + """ + rates = [] + for (whenprev, prev), (when, current) in zip(points, points[1:]): + days = (when - whenprev).total_seconds() / 86400 + if days <= 0: + continue + drop = prev - current + if drop < 0: + continue + rates.append(drop / days) + return rates + + +def _median(values): + ordered = sorted(values) + count = len(ordered) + if not count: + return None + middle = count // 2 + if count % 2: + return ordered[middle] + return (ordered[middle - 1] + ordered[middle]) / 2 + + def burn_rate(points): """Percent consumed per day over these readings, or None. + THE MEDIAN OF THE PER-INTERVAL RATES, not the slope between the first and + last reading. Two endpoints cannot tell "steady" from "burst then stopped": + a cartridge that lost 20 percent in two days and then barely moved for a + month reads as 0.83 percent/day forever after, so the report keeps promising + it will run out long after printing slowed. The burst is one interval among + many to a median, and one of two points to a secant. + None means "no honest estimate": too few readings, no elapsed time, or a drop too small to distinguish from a gauge that has not moved yet. """ if len(points) < MIN_POINTS_FOR_ESTIMATE: return None - first_when, first_level = points[0] - last_when, last_level = points[-1] - days = (last_when - first_when).total_seconds() / 86400 - if days <= 0: + total_days = (points[-1][0] - points[0][0]).total_seconds() / 86400 + if total_days <= 0: return None - drop = first_level - last_level - if drop < MIN_DROP_FOR_ESTIMATE: + # The overall drop still gates the estimate: a gauge sitting on one plateau + # has not proved anything yet, whatever the intervals say. + if points[0][1] - points[-1][1] < MIN_DROP_FOR_ESTIMATE: return None - return drop / days + rate = _median(interval_rates(points)) + if not rate: + # Every interval flat or rising, yet the run dropped overall - the + # movement is all in intervals the median discarded. Fall back to the + # whole-run slope rather than reporting nothing. + return (points[0][1] - points[-1][1]) / total_days + return rate + + +def rate_is_unstable(points, factor=RATE_SPREAD_FACTOR): + """True when the intervals disagree enough that one number oversells it.""" + rates = [r for r in interval_rates(points) if r > 0] + if len(rates) < 2: + return False + return max(rates) >= min(rates) * factor def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None): @@ -131,6 +247,7 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None): points = normalise(points) result = { 'currentlevel': currentlevel, 'daysleft': None, 'burnrateperday': None, + 'rateunstable': False, 'reason': None, 'replacements': 0, 'lastreplaced': None, 'basisdays': 0, 'points': [], } @@ -182,6 +299,9 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None): result['burnrateperday'] = round(rate, 2) result['daysleft'] = max(0, int(level / rate)) + # Say so when the intervals disagree wildly. The number is still the best + # estimate available; the flag stops it reading as a measurement. + result['rateunstable'] = rate_is_unstable(run) return result diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py index fdbd3b42..57a1598d 100644 --- a/shopdb/core/api/assets.py +++ b/shopdb/core/api/assets.py @@ -506,6 +506,17 @@ def update_asset(asset_id: int): if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + # An UPDATE may not blank the asset number. Create validates it and the + # column is NOT NULL, but the conflict check below only runs when the value + # DIFFERS, and '' never collides with anything - so a payload carrying an + # empty assetnumber wrote it straight through. A form that loaded blank + # (a failed reference load, a partial fetch) then saved the blank over a + # real record. + if 'assetnumber' in data and not (data['assetnumber'] or '').strip(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'assetnumber cannot be empty') + # Check for conflicting assetnumber if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): diff --git a/tests/test_plugins/test_collector_deviceid.py b/tests/test_plugins/test_collector_deviceid.py index 5a721afd..0f2f84cc 100644 --- a/tests/test_plugins/test_collector_deviceid.py +++ b/tests/test_plugins/test_collector_deviceid.py @@ -102,8 +102,25 @@ def _controlled(pcname, label): # --------------------------------------------------------------- part markers +def _retire(db, hostname): + """Move a PC off In Use, which is the one-step way to yield its device.""" + from shopdb.core.models import AssetStatus + retired = AssetStatus.query.filter_by(status='Retired').first() + if retired is None: + retired = AssetStatus(status='Retired') + db.session.add(retired) + db.session.flush() + pc = Asset.query.filter(Asset.assetnumber.ilike(hostname)).first() + pc.statusid = retired.statusid + db.session.commit() + + def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key): - """THE case this exists for. Same physical marker, two different PCs.""" + """THE case this exists for. Same physical marker, two different PCs. + + Neither PC mints a twin. Who HOLDS the marker is settled separately, by the + two tests below - this one pins only that the physical unit stays one row. + """ _asset(db, '0613') marker = _marker(db, 'PM-0613-A') @@ -120,12 +137,51 @@ def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key) assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None - assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A'] assert Asset.query.filter_by(assetnumber='PM-0613-A').count() == 1 assert marker.assetid == Asset.query.filter_by( assetnumber='PM-0613-A').first().assetid +def test_a_live_incumbent_keeps_the_marker_and_the_challenger_is_dormant( + client, db, rig, collector_key): + """Two PCs naming one device must not both hold it actively. + + Before this, neither device path looked at who else held the target, so a + replaced PC kept its link forever and a copied asset-id.txt claimed the same + marker from every bay, silently. + """ + _asset(db, '0613') + _marker(db, 'PM-0613-A') + _report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + resp = _report(client, collector_key, 'FMARK200', + pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + + assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A'] + assert _controlled('FMARK200', 'collector:partmarker') == [] + warnings = ' '.join(resp.get_json()['data'].get('warnings', [])) + assert 'FMARK100' in warnings and 'PM-0613-A' in warnings + + +def test_handover_completes_once_the_incumbent_yields(client, db, rig, + collector_key): + """The swap case as it actually happens: the old PC is retired or goes quiet.""" + _asset(db, '0613') + _marker(db, 'PM-0613-A') + _report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + _retire(db, 'FMARK100') + + _report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + + assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A'] + # Archived, never deleted: "which PC drove this in June" stays answerable. + assert _controlled('FMARK100', 'collector:partmarker') == [] + assert AssetRelationship.query.filter_by(label='collector:partmarker').count() >= 2 + + def test_without_the_file_a_swap_still_mints_the_old_way(client, db, rig, collector_key): """The unfixed behaviour, pinned so the file's value stays visible.""" @@ -150,6 +206,11 @@ def test_an_unknown_device_warns_and_links_nothing(client, db, rig, assert Asset.query.filter_by(assetnumber='PM-TYPO').first() is None warnings = ' '.join(resp.get_json()['data'].get('warnings', [])) assert 'PM-TYPO' in warnings + # LINKS NOTHING, which is what the name claims. This used to warn and then + # mint FMARK500-PARTMARKER anyway - the twin the whole path exists to stop - + # and the test passed because it only checked that PM-TYPO was not created. + assert Asset.query.filter_by(assetnumber='FMARK500-PARTMARKER').first() is None + assert _controlled('FMARK500', 'collector:partmarker') == [] def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key): @@ -160,7 +221,8 @@ def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key): pctype='gea-shopfloor-partmarker', machinenumber='0617', deviceid='PLAIN-MACHINE') assert resp.status_code in (200, 201) - assert _controlled('FMARK600', 'collector:partmarker') != ['PLAIN-MACHINE'] + assert _controlled('FMARK600', 'collector:partmarker') == [] + assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None warnings = ' '.join(resp.get_json()['data'].get('warnings', [])) assert 'PLAIN-MACHINE' in warnings diff --git a/tests/test_plugins/test_supply_history.py b/tests/test_plugins/test_supply_history.py index 396d8d46..90d17bfd 100644 --- a/tests/test_plugins/test_supply_history.py +++ b/tests/test_plugins/test_supply_history.py @@ -267,3 +267,75 @@ def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db): assert 'not configured' in body['reason'] assert body['cartridges'] == [] assert body['orderlist'] == [] + + +# -------------------------------------------------------------------------- +# Noise that used to read as cartridge changes, and bursts that used to bias +# the rate for the life of the cartridge. Both were reported from the floor: +# "5 changes in 90 days, that's hard to believe", and a cartridge that dropped +# 20 percent in two days then barely moved. +# -------------------------------------------------------------------------- + +def test_a_poll_returning_zero_is_not_a_cartridge_change(): + """0 then a real level is an SNMP error or a calibrating printer. + + The rise is +60, which cleared the old threshold on its own. It does not + land near full, so it is not a swap. + """ + points = normalise(series([70, 60, 0, 60, 55, 50])) + assert find_replacements(points) == [] + + +def test_a_gauge_ticking_back_up_mid_range_is_not_a_change(): + """A coarse gauge after a reseat or a power cycle. Lands at 55, not full.""" + points = normalise(series([70, 60, 45, 55, 50, 45])) + assert find_replacements(points) == [] + + +def test_a_real_swap_is_still_counted(): + """Near-empty to near-full. The shape a cartridge change actually makes.""" + points = normalise(series([30, 15, 5, 100, 95, 90])) + assert len(find_replacements(points)) == 1 + + +def test_the_run_starts_at_the_real_swap_not_at_the_noise(): + """current_run and find_replacements must agree on what a change is. + + They read the same predicate now; when they did not, a phantom rise reset + the run and threw away the history the estimate needed. + """ + points = normalise(series([90, 0, 85, 80, 75, 70])) + assert find_replacements(points) == [] + assert len(current_run(points)) == len(points) + + +def test_an_early_burst_does_not_dominate_the_rate_forever(): + """20 percent in two days, then a month of almost nothing. + + The endpoint slope reads the burst forever: (100-75)/30 = 0.83 %/day, so + the report keeps promising the cartridge runs out long after printing + stopped. The median sees one fast interval among many quiet ones. + """ + levels = [100, 90, 80] + [80 - i * 0.2 for i in range(1, 28)] + rate = burn_rate(normalise(series(levels))) + assert rate is not None + assert rate < 1.0, rate + detail = analyse(series(levels)) + assert detail['rateunstable'] is True + + +def test_a_steady_cartridge_is_not_flagged_unstable(): + detail = analyse(series([100, 95, 90, 85, 80, 75, 70])) + assert detail['burnrateperday'] == 5.0 + assert detail['rateunstable'] is False + + +def test_a_real_near_empty_reading_before_a_swap_is_kept(): + """30, 5, 100 is a cartridge run to the end and changed - not a spike. + + The dip filter must not eat it: the 5 does not RECOVER to 30, it jumps to + full, which is the shape of a swap rather than of a bad poll. + """ + points = normalise(series([40, 30, 5, 100, 95, 90])) + assert 5.0 in [level for _, level in points] + assert len(find_replacements(points)) == 1 From 4f0399b8ac1d0437057f906cba304f651fc81e0b Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 20 Aug 2026 17:23:59 -0400 Subject: [PATCH 03/15] A toner rate needs days behind it --- plugins/printers/services/supply_history.py | 17 +++++++++++++-- tests/test_plugins/test_supply_history.py | 23 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/plugins/printers/services/supply_history.py b/plugins/printers/services/supply_history.py index e3e6bda3..9f3b16f2 100644 --- a/plugins/printers/services/supply_history.py +++ b/plugins/printers/services/supply_history.py @@ -37,6 +37,18 @@ NEW_CARTRIDGE_LEVEL = 80 # through a coarse gauge can "prove" any rate at all. MIN_POINTS_FOR_ESTIMATE = 4 +# A rate needs TIME behind it, not just readings. Supply items are often polled +# every few minutes, so four readings can span a quarter of an hour - and a 2 +# point drop across fifteen minutes extrapolates to nearly 200 percent a day, +# which is how a cartridge sitting at 82 percent gets forecast to run out in a +# fortnight. The history window can be short for the same reason: the fetch +# keeps the newest rows up to a per-item cap, so a fast-polled item returns days +# rather than the ninety asked for. +# +# Two days is the smallest span that survives a printer's daily rhythm - a +# single heavy morning does not become the whole picture. +MIN_DAYS_FOR_ESTIMATE = 2 + # Many printers report in 10% steps, so a fortnight can pass on one plateau. # Without a minimum observed drop the slope reads as zero and the forecast # says "never", which is worse than saying nothing. @@ -201,7 +213,7 @@ def burn_rate(points): if len(points) < MIN_POINTS_FOR_ESTIMATE: return None total_days = (points[-1][0] - points[0][0]).total_seconds() / 86400 - if total_days <= 0: + if total_days < MIN_DAYS_FOR_ESTIMATE: return None # The overall drop still gates the estimate: a gauge sitting on one plateau # has not proved anything yet, whatever the intervals say. @@ -290,7 +302,8 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None): if rate is None: # Say which of the three it is; "no estimate" alone invites a bug report. - if len(run) < MIN_POINTS_FOR_ESTIMATE: + basis = result.get('basisdays') or 0 + if len(run) < MIN_POINTS_FOR_ESTIMATE or basis < MIN_DAYS_FOR_ESTIMATE: result['reason'] = ('replaced recently' if replacements else 'not enough history yet') else: diff --git a/tests/test_plugins/test_supply_history.py b/tests/test_plugins/test_supply_history.py index 90d17bfd..0d8e1ba5 100644 --- a/tests/test_plugins/test_supply_history.py +++ b/tests/test_plugins/test_supply_history.py @@ -339,3 +339,26 @@ def test_a_real_near_empty_reading_before_a_swap_is_kept(): points = normalise(series([40, 30, 5, 100, 95, 90])) assert 5.0 in [level for _, level in points] assert len(find_replacements(points)) == 1 + + +def test_minutes_of_readings_do_not_forecast_weeks(): + """Supply items are often polled every few minutes. + + Four readings a quarter of an hour apart, with a 2 point drop between the + ends, used to extrapolate to nearly 200 percent a day - so a cartridge + sitting at 82 percent was forecast to run out in a fortnight. A rate needs + time behind it, and without it the honest answer is no estimate. + """ + minutes = 5 + detail = analyse(series([84, 83, 83, 82], hours=minutes / 60)) + assert detail['burnrateperday'] is None + assert detail['daysleft'] is None + assert detail['reason'] == 'not enough history yet' + assert band(detail['daysleft']) is None # lands in "No estimate yet" + + +def test_a_run_with_enough_days_still_estimates(): + """The guard must not silence a genuinely slow, genuinely long run.""" + detail = analyse(series([84, 83, 82, 81, 80, 79])) + assert detail['burnrateperday'] == 1.0 + assert detail['daysleft'] == 79 From 5a58d336236b029c98ac6d4ee6680822d2a6cdf6 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Fri, 21 Aug 2026 08:50:00 -0400 Subject: [PATCH 04/15] Fix what the last round of device fixes broke, and two dips it missed --- plugins/computers/plugin.py | 53 +++++-- .../frontend/views/NetworkDeviceForm.vue | 18 ++- .../printers/frontend/views/TonerForecast.vue | 13 +- plugins/printers/services/supply_history.py | 74 +++++++-- plugins/tools/frontend/labelPages.js | 150 ++++++++++++++++++ plugins/tools/frontend/labelPages.spec.js | 113 +++++++++++++ tests/test_plugins/test_collector_deviceid.py | 57 +++++++ tests/test_plugins/test_supply_history.py | 65 +++++++- 8 files changed, 517 insertions(+), 26 deletions(-) create mode 100644 plugins/tools/frontend/labelPages.js create mode 100644 plugins/tools/frontend/labelPages.spec.js diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index e826c422..96f1e6b6 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -415,11 +415,13 @@ class ComputersPlugin(BasePlugin): partmarkers = self._sync_partmarker(comp, pctype, machinenumber, warnings, deviceid=deviceid) - # PC -> machine link from the reported machine number. - if partmarkers: - machinelinks = [] - else: + # PC -> machine link from the reported machine number. Only a PC that + # is NOT a marker PC (None above) takes it: a marker PC reaches its + # operation through `partof` on the marker, never by claiming it. + if partmarkers is None: machinelinks = self._sync_machine_link(comp, machinenumber, warnings) + else: + machinelinks = [] # Printer relationship sync (only when the payload carried printer data). printerlinks = self._sync_printer_links(comp.asset, payload, warnings) @@ -444,7 +446,10 @@ class ComputersPlugin(BasePlugin): 'measuringtoollinkcount': len(measuringtoollinks), 'accessprotocols': accessprotocols, 'machinelinks': machinelinks, - 'partmarkers': partmarkers, + # `or []`: None is the internal "not a marker PC" signal and + # has no business in the response, where it would read as a + # different state from "a marker PC that linked nothing". + 'partmarkers': partmarkers or [], }, } @@ -856,10 +861,21 @@ class ComputersPlugin(BasePlugin): if not controls or not deviceasset or not pcasset: return True + # EVERY collector-owned holder, not just ones under this label. On a + # CMM the instrument IS the reported bay, so the incumbent's link is the + # machine sync's row under MACHINE_LINK_ORIGIN. Filtering on our own + # label found no incumbent there, handed the challenger an active link, + # and left two live holders of one instrument - each invisible to the + # other because they were labelled differently. + # + # A row made by hand carries neither label and is deliberately NOT + # counted: a person's link is not the collector's to archive. + ourlabels = (label, MACHINE_LINK_ORIGIN, MEASURINGTOOL_LINK_ORIGIN, + PARTMARKER_LINK_ORIGIN) others = AssetRelationship.query.filter( AssetRelationship.targetassetid == deviceasset.assetid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid, - AssetRelationship.label == label, + AssetRelationship.label.in_(ourlabels), AssetRelationship.isactive.is_(True), AssetRelationship.sourceassetid != pcasset.assetid, ).all() @@ -923,7 +939,16 @@ class ComputersPlugin(BasePlugin): # Only a device that FILES UNDER an operation goes through here. # A measuring tool is a subordinate device too, but it does not # share a machine number, so it keeps the simpler path. - return [] + # + # NONE, not []. None is the ONLY answer that means "not a marker PC, + # run the ordinary machine link". Every other exit below means "this + # IS a marker PC" - possibly one that linked nothing - and returning + # [] there let the caller fall through to the machine link, which + # for a marker PC points at the OPERATION. That is the direct claim + # this whole path exists to prevent: several markers serve one + # operation, so two marker PCs would contest a link that can only + # have one holder, while the warning said "not linked". + return None pcasset = comp.asset controls = RelationshipType.query.filter_by( @@ -998,8 +1023,15 @@ class ComputersPlugin(BasePlugin): self._ensure_device_rows(markerasset, spec, pcasset, controls, label, warnings, active=mayhold) elif reuse: - reuse.isactive = True + # Re-check incumbency HERE too, not only on the named path. A + # challenger recorded dormant keeps its row, so the cycle after its + # asset-id.txt goes missing found that row as `reuse` and flipped it + # active with no incumbent check and no warning - two live holders, + # arrived at by deleting a file. _sync_machine_link never had this + # hole because it re-checks unconditionally. markerasset = db.session.get(Asset, reuse.targetassetid) + reuse.isactive = self._device_incumbents_yield( + markerasset, pcasset, label, warnings) else: coretype = AssetType.query.filter_by( assettype=spec['assettype']).first() @@ -1419,8 +1451,11 @@ class ComputersPlugin(BasePlugin): tooltype.measuringtooltypeid targetid = adopted.assetid elif reuse: - reuse.isactive = True + # Same re-check as the marker path: a dormant challenger must not be + # promoted just because its enrollment file stopped being readable. toolasset = db.session.get(Asset, reuse.targetassetid) + reuse.isactive = self._device_incumbents_yield( + toolasset, pcasset, MEASURINGTOOL_LINK_ORIGIN, warnings) if toolasset and toolasset.measuringtool and tooltype: toolasset.measuringtool.measuringtooltypeid = \ tooltype.measuringtooltypeid diff --git a/plugins/network/frontend/views/NetworkDeviceForm.vue b/plugins/network/frontend/views/NetworkDeviceForm.vue index 526e9d99..3559b7e2 100644 --- a/plugins/network/frontend/views/NetworkDeviceForm.vue +++ b/plugins/network/frontend/views/NetworkDeviceForm.vue @@ -24,9 +24,18 @@ type="text" class="form-control" :required="isEdit" - :disabled="isEdit" + :disabled="isEdit && !assetNumberMissing" :placeholder="generatedAssetNumber || 'leave blank to generate from the name'" /> + + + This device has no asset number. Give it one to save. + Will be created as {{ generatedAssetNumber }} @@ -393,6 +402,10 @@ const generatedAssetNumber = computed(() => { if (name.toUpperCase().startsWith(prefix + '-')) return name return `${prefix}-${name}` }) +// True when the record loaded WITHOUT an asset number, which unlocks the field +// so it can be repaired. Captured at load: once typed, the field must stay open. +const assetNumberMissing = ref(false) + const vendors = ref([]) const models = ref([]) @@ -480,6 +493,9 @@ async function loadDevice() { // Populate form with existing data form.value.assetnumber = data.assetnumber || '' + // Remember that it arrived empty, so the field stays editable while the + // user types the number this record should have had. + assetNumberMissing.value = !(data.assetnumber || '').trim() form.value.name = data.name || '' form.value.serialnumber = data.serialnumber || '' form.value.gaugelabreference = data.gaugelabreference || '' diff --git a/plugins/printers/frontend/views/TonerForecast.vue b/plugins/printers/frontend/views/TonerForecast.vue index 98841259..6d254b8d 100644 --- a/plugins/printers/frontend/views/TonerForecast.vue +++ b/plugins/printers/frontend/views/TonerForecast.vue @@ -90,7 +90,7 @@ Level Runs out Rate - Replacements + Replacements @@ -131,10 +131,15 @@ - + diff --git a/plugins/printers/services/supply_history.py b/plugins/printers/services/supply_history.py index 9f3b16f2..f9e2e701 100644 --- a/plugins/printers/services/supply_history.py +++ b/plugins/printers/services/supply_history.py @@ -33,6 +33,20 @@ REPLACEMENT_RISE = 10 # while a phantom swap resets the run and throws the estimate away entirely. NEW_CARTRIDGE_LEVEL = 80 +# How far ABOVE its previous level a reading may come back and still count as a +# recovery rather than a new cartridge. Small, because it exists for gauge +# noise: a real swap returns near full, well past this. +RECOVERY_TOLERANCE = 2 + +# How long a dip may last and still be a bad reading rather than real use. +# Levels alone cannot separate the two: a swap to full, ordinary consumption, +# then another swap has the same SHAPE as an outage that recovers. What differs +# is elapsed time. A supply out of the machine, a door open or a bad poll spans +# minutes to an hour or two at the few-minute polling these items use; a level +# that stays down for days is genuinely down, and deleting those readings would +# hide a real empty period. +MAX_DIP_HOURS = 6 + # Below this many readings a slope is arithmetic, not evidence. Two points # through a coarse gauge can "prove" any rate at all. MIN_POINTS_FOR_ESTIMATE = 4 @@ -92,8 +106,23 @@ def normalise(points): return drop_spikes(out) +def _recovered(previous, following, tolerance=RECOVERY_TOLERANCE): + """Did the level come BACK to where it was, rather than up to a new one? + + Toner only falls, so a dip that recovers returns to at or below the level it + left - consumption carried on while the reading was junk. A cartridge that + was CHANGED comes back HIGHER than the level before the dip. + + That asymmetry is the whole test. Comparing the absolute difference instead + treated a genuine swap of a nearly-full cartridge (95, then 5, then 100) as + a recovery and deleted the evidence, because 100 and 95 are close. The small + upward tolerance is for gauge noise, not for swaps. + """ + return following <= previous + tolerance + + def drop_spikes(points, rise=REPLACEMENT_RISE): - """Remove one-reading dips that RECOVER to where they came from. + """Remove dips that RECOVER to where they came from, however long they run. A single reading far below both neighbours, then a recovery, is a big upward step that scores as a cartridge change. That is how a cartridge @@ -108,22 +137,45 @@ def drop_spikes(points, rise=REPLACEMENT_RISE): The filter keys on SHAPE rather than cause, which is why it holds for all of them: a level that comes back to where it was did not get a new cartridge. - Only a dip that comes BACK to roughly its previous level is removed. A - genuine near-empty reading before a swap (30, 5, 100) does not recover - it - jumps to full - so it is kept, and the swap after it still counts. + ANY LENGTH, not just one reading. At the few-minute polling these items + often use, a door left open or a supply out of the machine spans several + polls, and a filter that only removed single readings left the original + failure in place for every cartridge above NEW_CARTRIDGE_LEVEL. + + A genuine near-empty reading before a swap (30, 5, 100) does not recover - + it comes back HIGHER than 30 - so it is kept and the swap still counts. """ if len(points) < 3: return points out = [points[0]] - for index in range(1, len(points) - 1): + index = 1 + while index < len(points) - 1: previous = points[index - 1][1] - current = points[index][1] - following = points[index + 1][1] - dipped = (previous - current) >= rise and (following - current) >= rise - recovered = abs(following - previous) <= rise - if dipped and recovered: + if (previous - points[index][1]) < rise: + out.append(points[index]) + index += 1 continue - out.append(points[index]) + # A dip starts here. Take every consecutive reading that stays down. + end = index + while end < len(points) - 1 and (previous - points[end][1]) >= rise: + end += 1 + following = points[end][1] + # ONE bad sample is a candidate whatever the cadence: the reading is an + # instant, and the gap to its neighbours says nothing about how long the + # supply was actually out. SEVERAL consecutive low readings only count + # as one outage if they are close together - spread over days they are a + # real absence, and they also have the same shape as a swap, ordinary + # consumption, then another swap. + spanhours = (points[end][0] - points[index - 1][0]).total_seconds() / 3600 + brief = (end - index) == 1 or spanhours <= MAX_DIP_HOURS + if (brief + and (following - points[end - 1][1]) >= rise + and _recovered(previous, following)): + index = end # drop the whole stretch + continue + for keep in range(index, end): + out.append(points[keep]) + index = end out.append(points[-1]) return out diff --git a/plugins/tools/frontend/labelPages.js b/plugins/tools/frontend/labelPages.js new file mode 100644 index 00000000..7b6e4546 --- /dev/null +++ b/plugins/tools/frontend/labelPages.js @@ -0,0 +1,150 @@ +// Rows in, printed pages out. +// +// This is the half of the generator a preview cannot check. Two things live +// here for that reason: the CSV columns, which are a contract with whoever +// builds the file, and the PAGE ORDER of a two-sided card, which decides +// whether card 3's back lands on card 3 or on card 4. Both are the kind of +// wrong you only find once the stock is used up. +// +// Nothing here knows how a code is drawn or how big a label is. That is +// utils/codes.js and the component's CSS respectively. + +// Rendering thousands of codes locks the tab, so stop at a number a person +// would actually feed a label printer in one run. The cap counts CARDS, not +// pages - a two-sided run of 500 is 1000 pages and that is fine. +export const MAX_LABELS = 500 +export const MAX_COPIES = 1000 + +const CONTENT_HEADERS = ['content', 'text', 'data', 'value', 'url', 'qr'] +const LABEL_HEADERS = ['label', 'name', 'caption', 'description'] +const COPIES_HEADERS = ['copies', 'qty', 'quantity', 'count'] +const BACK_HEADERS = ['back', 'backcontent', 'reverse'] +const BACKLABEL_HEADERS = ['backlabel', 'backcaption', 'backname'] + +/** Split one CSV line, honoring quoted fields and "" escapes. */ +export function splitCsvLine(line) { + const fields = [] + let current = '' + let inQuotes = false + for (let i = 0; i < line.length; i++) { + const char = line[i] + if (inQuotes) { + if (char === '"' && line[i + 1] === '"') { current += '"'; i++ } + else if (char === '"') { inQuotes = false } + else { current += char } + } else if (char === '"') { + inQuotes = true + } else if (char === ',') { + fields.push(current); current = '' + } else { + current += char + } + } + fields.push(current) + return fields.map(field => field.trim()) +} + +/** + * Parse the label CSV into rows. + * + * Columns: content (required), label, copies, back, backlabel. A header row is + * optional; without one the order is the order above. back/backlabel are only + * read when the back side is set to take its own content per row. + */ +export function parseCsv(text) { + const lines = String(text || '').split(/\r?\n/).filter(line => line.trim() !== '') + if (!lines.length) return [] + + let columns = { content: 0, label: 1, copies: 2, back: 3, backlabel: 4 } + let start = 0 + const first = splitCsvLine(lines[0]).map(field => field.toLowerCase()) + if (first.some(field => CONTENT_HEADERS.includes(field))) { + // Named header: map by name so column order does not matter. + const indexOf = names => first.findIndex(field => names.includes(field)) + columns = { + content: indexOf(CONTENT_HEADERS), + label: indexOf(LABEL_HEADERS), + copies: indexOf(COPIES_HEADERS), + back: indexOf(BACK_HEADERS), + backlabel: indexOf(BACKLABEL_HEADERS), + } + start = 1 + } + + const at = (fields, index) => (index >= 0 ? (fields[index] || '') : '') + + const rows = [] + for (let i = start; i < lines.length; i++) { + const fields = splitCsvLine(lines[i]) + const content = at(fields, columns.content) + if (!content) continue + const copies = columns.copies >= 0 ? parseInt(fields[columns.copies], 10) : 1 + rows.push({ + content, + label: at(fields, columns.label), + copies: Number.isFinite(copies) && copies > 0 ? Math.min(copies, MAX_COPIES) : 1, + back: at(fields, columns.back), + backlabel: at(fields, columns.backlabel), + }) + } + return rows +} + +/** The back page belonging to one row, per the chosen back source. */ +function backPageFor(row, back) { + const fallback = back.text || '' + switch (back.source) { + // The same payload again, so either face of a card scans. Worth it on a + // badge that can end up in a holder either way round. + case 'code': + return { content: row.content, label: row.label || '', side: 'back' } + // Per-row back: the CSV carries it. A row that left it empty still gets a + // page, because a card printer counts pages and a missing one shifts every + // back after it onto the wrong front. + case 'column': + return { content: row.back || '', label: row.backlabel || fallback, side: 'back' } + case 'blank': + return { content: '', label: '', side: 'back' } + case 'text': + default: + return { content: '', label: fallback, side: 'back' } + } +} + +/** + * Expand rows by their copies count and, when a back side is enabled, pair + * each card with its back page. + * + * `back.order` is the one that matters on the floor: + * interleave - front, back, front, back. What a duplex card printer's driver + * expects: it takes pages two at a time. + * grouped - every front, then every back. For a single-sided printer, + * where the stack comes out, gets flipped and goes back in. + * Getting this backwards prints readable cards with the wrong names on the + * back, which is worse than a jam because nothing looks broken. + */ +export function buildPages(rows, options = {}) { + const { back = {}, max = MAX_LABELS } = options + const fronts = [] + const backs = [] + let dropped = 0 + + for (const row of rows || []) { + const wanted = Number.isFinite(row.copies) && row.copies > 0 + ? Math.min(row.copies, MAX_COPIES) : 1 + for (let i = 0; i < wanted; i++) { + if (fronts.length >= max) { dropped += 1; continue } + fronts.push({ content: row.content, label: row.label || '', side: 'front' }) + if (back.enabled) backs.push(backPageFor(row, back)) + } + } + + let pages = fronts + if (back.enabled) { + pages = back.order === 'grouped' + ? [...fronts, ...backs] + : fronts.flatMap((front, index) => [front, backs[index]]) + } + + return { pages, fronts, backs, dropped } +} diff --git a/plugins/tools/frontend/labelPages.spec.js b/plugins/tools/frontend/labelPages.spec.js new file mode 100644 index 00000000..31d33fbc --- /dev/null +++ b/plugins/tools/frontend/labelPages.spec.js @@ -0,0 +1,113 @@ +// Page order is the one thing here that costs money to get wrong: a run of +// badge cards with the backs one card out looks perfect until someone reads +// one. These tests pin the order and the CSV contract. + +import { describe, it, expect } from 'vitest' + +import { parseCsv, splitCsvLine, buildPages, MAX_LABELS } from './labelPages' + +const sides = pages => pages.map(page => page.side).join(' ') +const contents = pages => pages.map(page => page.content || '-').join(' ') + +describe('splitCsvLine', () => { + it('keeps a comma that is inside quotes', () => { + expect(splitCsvLine('WJ-0001,"Bay 12, press",2')).toEqual(['WJ-0001', 'Bay 12, press', '2']) + }) + + it('unescapes a doubled quote', () => { + expect(splitCsvLine('x,"the ""big"" one"')).toEqual(['x', 'the "big" one']) + }) +}) + +describe('parseCsv', () => { + it('reads a named header in any column order', () => { + const rows = parseCsv('copies,label,content\n3,Line 1,WJ-0001') + expect(rows).toEqual([ + { content: 'WJ-0001', label: 'Line 1', copies: 3, back: '', backlabel: '' }, + ]) + }) + + it('falls back to positional columns with no header', () => { + const rows = parseCsv('WJ-0001,Line 1,2,WJ-0001-B,Reverse') + expect(rows[0]).toEqual({ + content: 'WJ-0001', label: 'Line 1', copies: 2, + back: 'WJ-0001-B', backlabel: 'Reverse', + }) + }) + + it('skips a row with no content rather than printing a blank sticker', () => { + expect(parseCsv('content,label\n,orphan\nWJ-0002,real')).toHaveLength(1) + }) + + it('defaults copies to 1 when it is missing or junk', () => { + expect(parseCsv('content,copies\nA,\nB,zero')[0].copies).toBe(1) + expect(parseCsv('content,copies\nA,\nB,zero')[1].copies).toBe(1) + }) +}) + +describe('buildPages without a back side', () => { + it('expands copies and emits fronts only', () => { + const { pages, backs } = buildPages([{ content: 'A', label: 'a', copies: 3 }]) + expect(sides(pages)).toBe('front front front') + expect(backs).toHaveLength(0) + }) + + it('caps cards, not pages, and says how many it dropped', () => { + const { fronts, dropped } = buildPages([{ content: 'A', copies: MAX_LABELS + 5 }]) + expect(fronts).toHaveLength(MAX_LABELS) + expect(dropped).toBe(5) + }) +}) + +describe('buildPages with a back side', () => { + const rows = [ + { content: 'A', label: 'a', copies: 2, back: 'A2', backlabel: 'a2' }, + { content: 'B', label: 'b', copies: 1, back: 'B2', backlabel: 'b2' }, + ] + + it('interleaves front and back, which is what a duplex driver takes', () => { + const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'interleave' } }) + expect(sides(pages)).toBe('front back front back front back') + expect(contents(pages)).toBe('A A2 A A2 B B2') + }) + + it('groups every front then every back, for a flip-and-reload printer', () => { + const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'grouped' } }) + expect(sides(pages)).toBe('front front front back back back') + // Same card order in both halves, or the flipped stack pairs up wrong. + expect(contents(pages)).toBe('A A B A2 A2 B2') + }) + + it('still emits a back for a row that left the back column empty', () => { + const { pages } = buildPages( + [{ content: 'A', copies: 1 }, { content: 'B', copies: 1, back: 'B2' }], + { back: { enabled: true, source: 'column', order: 'interleave' } }) + // A page count that changes per row would shift every later back onto the + // wrong front. + expect(sides(pages)).toBe('front back front back') + expect(contents(pages)).toBe('A - B B2') + }) + + it('repeats the front payload when the back is the same code', () => { + const { backs } = buildPages(rows, { back: { enabled: true, source: 'code' } }) + expect(contents(backs)).toBe('A A B') + }) + + it('puts the same fixed text on every back and no code', () => { + const { backs } = buildPages(rows, { back: { enabled: true, source: 'text', text: 'Property of GE' } }) + expect(backs.every(page => page.content === '')).toBe(true) + expect(backs.every(page => page.label === 'Property of GE')).toBe(true) + }) + + it('leaves a blank back entirely empty but still counted', () => { + const { pages, backs } = buildPages(rows, { back: { enabled: true, source: 'blank' } }) + expect(pages).toHaveLength(6) + expect(backs.every(page => !page.content && !page.label)).toBe(true) + }) + + it('falls back to the fixed text when a row has no back label', () => { + const { backs } = buildPages([{ content: 'A', copies: 1, back: 'A2' }], + { back: { enabled: true, source: 'column', text: 'Return to IT' } }) + expect(backs[0].label).toBe('Return to IT') + }) +}) diff --git a/tests/test_plugins/test_collector_deviceid.py b/tests/test_plugins/test_collector_deviceid.py index 0f2f84cc..708dbdeb 100644 --- a/tests/test_plugins/test_collector_deviceid.py +++ b/tests/test_plugins/test_collector_deviceid.py @@ -211,6 +211,11 @@ def test_an_unknown_device_warns_and_links_nothing(client, db, rig, # and the test passed because it only checked that PM-TYPO was not created. assert Asset.query.filter_by(assetnumber='FMARK500-PARTMARKER').first() is None assert _controlled('FMARK500', 'collector:partmarker') == [] + # AND it must not fall through to claiming the OPERATION directly. A marker + # PC reports the operation as its machine number, so "link nothing" that + # let the ordinary machine link run traded a phantom marker for a contested + # operation - several marker PCs share one, and it holds a single link. + assert _controlled('FMARK500', 'collector:machine') == [] def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key): @@ -222,6 +227,7 @@ def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key): machinenumber='0617', deviceid='PLAIN-MACHINE') assert resp.status_code in (200, 201) assert _controlled('FMARK600', 'collector:partmarker') == [] + assert _controlled('FMARK600', 'collector:machine') == [] assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None warnings = ' '.join(resp.get_json()['data'].get('warnings', [])) assert 'PLAIN-MACHINE' in warnings @@ -267,3 +273,54 @@ def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key): deviceid='MT-9003', measuringtoolid='MT-9004') assert resp.status_code in (200, 201) assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003'] + + +def test_a_dormant_challenger_is_not_promoted_when_its_file_disappears( + client, db, rig, collector_key): + """Deleting asset-id.txt must not hand a device to the challenger. + + The dormant row this feature creates is found as `reuse` on the next cycle. + Reactivating it unconditionally meant a second PC took a live device by + losing a file - no warning, two active holders. + """ + _asset(db, '0613') + _marker(db, 'PM-0613-A') + _report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + _report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + assert _controlled('FMARK200', 'collector:partmarker') == [] + + # Next cycle: the file is gone, so no deviceid is sent at all. + _report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker', + machinenumber='0613') + + assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A'] + assert _controlled('FMARK200', 'collector:partmarker') == [] + + +def test_an_incumbent_under_another_collector_label_is_still_seen( + client, db, rig, collector_key): + """On a CMM the instrument IS the bay, so the incumbent's link is the + machine sync's row under collector:machine. Counting only our own label + left two live holders of one instrument, each invisible to the other. + """ + _tool(db, 'CMM4') + # A LIVE incumbent: it reported through the collector, so it has a computer + # row and a recent check-in. A bare asset would correctly be treated as + # yielded, since something that cannot report cannot be holding anything. + _report(client, collector_key, 'WCMM100', + pctype='gea-shopfloor-keyence', deviceid='CMM4') + # Relabel its link to the machine sync's origin, which is the shape a CMM + # really has: there the instrument IS the reported bay. + pc = Asset.query.filter(Asset.assetnumber.ilike('WCMM100')).first() + held = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).first() + held.label = 'collector:machine' + db.session.commit() + + resp = _report(client, collector_key, 'WCMM200', + pctype='gea-shopfloor-keyence', deviceid='CMM4') + + assert _controlled('WCMM200', 'collector:measuringtool') == [] + warnings = ' '.join(resp.get_json()['data'].get('warnings', [])) + assert 'CMM4' in warnings diff --git a/tests/test_plugins/test_supply_history.py b/tests/test_plugins/test_supply_history.py index 0d8e1ba5..7c34c74e 100644 --- a/tests/test_plugins/test_supply_history.py +++ b/tests/test_plugins/test_supply_history.py @@ -319,7 +319,9 @@ def test_an_early_burst_does_not_dominate_the_rate_forever(): levels = [100, 90, 80] + [80 - i * 0.2 for i in range(1, 28)] rate = burn_rate(normalise(series(levels))) assert rate is not None - assert rate < 1.0, rate + # The old endpoint slope gives ~0.88 for this series, so a threshold of 1.0 + # passed with the bug still in. The median gives ~0.2. + assert rate < 0.5, rate detail = analyse(series(levels)) assert detail['rateunstable'] is True @@ -362,3 +364,64 @@ def test_a_run_with_enough_days_still_estimates(): detail = analyse(series([84, 83, 82, 81, 80, 79])) assert detail['burnrateperday'] == 1.0 assert detail['daysleft'] == 79 + + +def test_a_multi_poll_outage_is_one_dip_not_a_cartridge_change(): + """A door open or a supply out of the machine spans several polls. + + These items are often polled every few minutes, so an outage covers more + than one reading. Removing only SINGLE readings left the original failure + in place for any cartridge above NEW_CARTRIDGE_LEVEL: 0 -> 90 clears the + rise and lands near full, so it scored as a swap. + """ + points = normalise(series([90, 0, 0, 90, 88, 86], hours=5 / 60)) + assert [level for _, level in points] == [90.0, 90.0, 88.0, 86.0] + assert find_replacements(points) == [] + + +def test_a_long_absence_is_not_deleted_as_noise(): + """Days at zero is a real empty period, not a bad poll. + + Only a BRIEF multi-reading dip is removed. Deleting a level that stayed + down for days would hide exactly the outage someone needs to see. + """ + points = normalise(series([90, 0, 0, 90, 88, 86])) + assert 0.0 in [level for _, level in points] + + +def test_a_swap_of_a_nearly_full_cartridge_is_not_eaten_by_the_dip_filter(): + """95, then 5, then 100 is a swap, not a dip that recovered. + + Comparing the absolute difference treated it as a recovery, because 100 and + 95 are close, and deleted the evidence: the run then spanned two cartridges + and the replacement went uncounted. Toner only falls, so a recovery comes + back at or BELOW where it left - a new cartridge comes back higher. + """ + points = normalise(series([98, 95, 5, 100, 96, 92])) + assert 5.0 in [level for _, level in points] + assert len(find_replacements(points)) == 1 + + +def test_consumption_between_two_swaps_is_not_mistaken_for_a_dip(): + """Swap to full, print for days, swap again: same shape, different thing. + + The readings between the swaps are far below both bounding levels, which is + what a dip looks like. What separates them is elapsed time. + """ + points = normalise(series([80, 60, 40, 20, 100, 80, 60, 95, 70])) + assert [level for _, level in points] == [80.0, 60.0, 40.0, 20.0, + 100.0, 80.0, 60.0, 95.0, 70.0] + assert len(find_replacements(points)) == 2 + + +def test_a_rise_that_stops_mid_range_is_not_a_cartridge_change(): + """Isolates the near-full rule from the dip filter. + + 50 -> 75 clears the rise and is NOT a recovering dip (75 comes back higher + than the 60 before it), so the dip filter leaves it alone. Only the rule + that a swap must LAND near full rejects it. A new cartridge does not read + 75 percent. + """ + points = normalise(series([60, 50, 75, 73, 71])) + assert 50.0 in [level for _, level in points] + assert find_replacements(points) == [] From 44432c9b5abf9e5349d1fb0dc3a7c9a16a73d924 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Fri, 21 Aug 2026 09:09:14 -0400 Subject: [PATCH 05/15] Label generator: gap, position and a back side --- CHANGELOG.md | 31 ++ frontend/src/utils/codes.js | 20 +- .../tools/frontend/views/CodeGenerator.vue | 406 ++++++++++++------ 3 files changed, 312 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c0779a..dbd0963d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,37 @@ ADR-007 and ADR-002. ## [Unreleased] +### Added + +- **The code generator can print a back side.** A badge card is two-sided and + the generator only ever emitted one page per card. The Back side section + turns on a second page per card carrying either the same code again, a fixed + block of text, its own per-row content (CSV `back` / `backlabel`), or + nothing. Page order is a choice, because the two kinds of printer want + opposite things: **interleaved** front-back-front-back for a duplex card + printer, **grouped** all-fronts-then-all-backs for a single-sided printer + that gets its stack flipped and reloaded. Getting that backwards prints + perfectly readable cards with the wrong back on every one, which is why the + order is pinned by tests rather than by looking at the preview. +- **The gap between a code and its caption is its own control.** It was the + quiet zone doing double duty: closing up a caption meant shaving the clear + space a scanner needs, and the only way to get a code to scan was to push the + caption away from it. Quiet zone now means only the code's own margin; gap + means only the distance to the caption, and adds to the quiet zone rather + than eating into it. Every stock preset carries a gap that suits it. +- **Contents can be positioned inside the label without moving the label.** + Align and Vertical place the code and caption block anywhere on the stock, + and Content X/Y nudge it from there. The existing X/Y nudge kept its + behaviour and is now labelled Media X/Y: it moves the whole printed area, for + a printer whose origin is off, which is a different problem from composing a + label. + +### Changed + +- CSV parsing and page building moved out of the generator component into + `plugins/tools/frontend/labelPages.js`, where the column contract and the + two-sided page order are covered by tests. + ## [0.12.0] - 2026-08-20 Printers become a property of the bay rather than of whoever last walked up to a diff --git a/frontend/src/utils/codes.js b/frontend/src/utils/codes.js index 381cba5c..cdeba7c7 100644 --- a/frontend/src/utils/codes.js +++ b/frontend/src/utils/codes.js @@ -24,15 +24,19 @@ import JsBarcode from 'jsbarcode' import { getQrLogo } from '@/utils/siteSettings' -// Label stock actually loaded in the printers, with the code size, padding and -// quiet zone that fit each one. Sizes are inches, because that is what the -// stock is sold as and what @page takes. +// Label stock actually loaded in the printers, with the code size, padding, +// quiet zone and code-to-caption gap that fit each one. Sizes are inches, +// because that is what the stock is sold as and what @page takes. +// +// quiet and gap are NOT the same measurement. quiet is the clear space a +// scanner needs around the code and is fixed by the symbology; gap is only how +// far the caption sits from it, and is a matter of taste per stock. export const LABEL_PRESETS = [ - { id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 }, - { id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 }, - { id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 }, - { id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 }, - { id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 }, + { id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, gap: 0.03, labelfont: 7 }, + { id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, gap: 0.05, labelfont: 10 }, + { id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, gap: 0.06, labelfont: 11 }, + { id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, gap: 0.12, labelfont: 20 }, + { id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, gap: 0.1, labelfont: 12 }, ] // CODE128 is what the scanners on the floor are configured for, and a label diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue index 6be1b832..9b1cac6c 100644 --- a/plugins/tools/frontend/views/CodeGenerator.vue +++ b/plugins/tools/frontend/views/CodeGenerator.vue @@ -65,8 +65,10 @@

{{ csvError }}

- Columns: content (required), label, copies. - A header row is optional; without one the order is content, label, copies. + Columns: content (required), label, copies, back, backlabel. + A header row is optional; without one the order is content, label, copies, back, backlabel.

@@ -99,11 +101,16 @@ + - -
+

+ The quiet zone is the clear space the SCANNER needs and it stays wrapped + around the code. The gap is only the space between the code and its + caption, and it adds to the quiet zone rather than eating into it. +

+ @@ -121,18 +128,107 @@

+ +

Position

-
+ +

+ Content moves the code and caption INSIDE the label and leaves the label + where it is. Media moves the whole printed area on the stock - that one + is for a printer whose origin is off, not for composing a label. +

+ + +

Back side

+
+ + +
+ +
+ + +
+ +

+ Duplex printer: keep page order interleaved and set the + driver to double-sided, flip on SHORT edge for portrait card stock - + long edge prints the back upside down. + Single-sided printer: choose grouped, print the run, + flip the whole stack keeping its order, and print the second half. + Print one card both ways before committing the box. +

+ +
+ -

- Showing the first {{ MAX_LABELS }} rows. {{ truncated }} more were left out + Showing the first {{ MAX_LABELS }} labels. {{ truncated }} more were left out - split the CSV and print it in batches.

@@ -145,17 +241,20 @@

-
- -
-
- {{ label.label }} +
+
+ +
+
+ {{ page.label }} +
+ {{ page.side }}
@@ -165,10 +264,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue' import { LABEL_PRESETS, qrModuleCount, qrFit, qrSvgDataUri, barcodeSvgDataUri } from '@/utils/codes' - -// Rendering thousands of codes locks the tab, so stop at a number a person -// would actually feed a label printer in one run and SAY what was dropped. -const MAX_LABELS = 500 +import { parseCsv, buildPages, MAX_LABELS } from '../labelPages' const PRESETS = LABEL_PRESETS @@ -179,6 +275,7 @@ const errorcorrection = ref('M') const singleContent = ref('') const singleLabel = ref('') const singleCopies = ref(1) +const singleBack = ref('') const csvText = ref('') const csvError = ref('') @@ -188,143 +285,112 @@ const labelwidth = ref(1.0) const labelheight = ref(0.5) const padding = ref(0) const quiet = ref(0.035) +const gap = ref(0.03) const codesize = ref(0.4286) const barheight = ref(0.3) const labelfont = ref(7) const layout = ref('side') +const align = ref('center') +const valign = ref('center') +const contentx = ref(0) +const contenty = ref(0) const nudgex = ref(0) const nudgey = ref(0) const dpi = ref(203) +const backenabled = ref(false) +const backsource = ref('text') +const backtext = ref('') +const backlayout = ref('textonly') +const backorder = ref('interleave') + const images = ref([]) const testMode = ref(false) const overlong = ref([]) const SETTING_KEYS = [ 'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding', - 'quiet', 'codesize', 'barheight', 'labelfont', 'layout', 'nudgex', 'nudgey', 'dpi', + 'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign', + 'contentx', 'contenty', 'nudgex', 'nudgey', 'dpi', + 'backenabled', 'backsource', 'backtext', 'backlayout', 'backorder', ] const settingRefs = { codetype, errorcorrection, preset, labelwidth, labelheight, padding, - quiet, codesize, barheight, labelfont, layout, nudgex, nudgey, dpi, + quiet, gap, codesize, barheight, labelfont, layout, align, valign, + contentx, contenty, nudgex, nudgey, dpi, + backenabled, backsource, backtext, backlayout, backorder, } // --- rows ------------------------------------------------------------------- -// Split one CSV line, honoring quoted fields and "" escapes. -function splitCsvLine(line) { - const fields = [] - let current = '' - let inQuotes = false - for (let i = 0; i < line.length; i++) { - const char = line[i] - if (inQuotes) { - if (char === '"' && line[i + 1] === '"') { current += '"'; i++ } - else if (char === '"') { inQuotes = false } - else { current += char } - } else if (char === '"') { - inQuotes = true - } else if (char === ',') { - fields.push(current); current = '' - } else { - current += char - } - } - fields.push(current) - return fields.map(field => field.trim()) -} - -const CONTENT_HEADERS = ['content', 'text', 'data', 'value', 'url', 'qr'] -const LABEL_HEADERS = ['label', 'name', 'caption', 'description'] -const COPIES_HEADERS = ['copies', 'qty', 'quantity', 'count'] - -function parseCsv(text) { - const lines = text.split(/\r?\n/).filter(line => line.trim() !== '') - if (!lines.length) return [] - - let columns = { content: 0, label: 1, copies: 2 } - let start = 0 - const first = splitCsvLine(lines[0]).map(field => field.toLowerCase()) - if (first.some(field => CONTENT_HEADERS.includes(field))) { - // Named header: map by name so column order does not matter. - const indexOf = names => first.findIndex(field => names.includes(field)) - columns = { - content: indexOf(CONTENT_HEADERS), - label: indexOf(LABEL_HEADERS), - copies: indexOf(COPIES_HEADERS), - } - start = 1 - } - - const rows = [] - for (let i = start; i < lines.length; i++) { - const fields = splitCsvLine(lines[i]) - const content = columns.content >= 0 ? (fields[columns.content] || '') : '' - if (!content) continue - const copies = columns.copies >= 0 ? parseInt(fields[columns.copies], 10) : 1 - rows.push({ - content, - label: columns.label >= 0 ? (fields[columns.label] || '') : '', - copies: Number.isFinite(copies) && copies > 0 ? Math.min(copies, 1000) : 1, - }) - } - return rows -} - -// Every row expanded by its copies count, capped. -const allLabels = computed(() => { - let rows = [] +// The rows before copies are expanded. CSV parsing and page building both live +// in ../labelPages.js, which is where they can be tested. +const rows = computed(() => { if (source.value === 'single') { const content = singleContent.value.trim() - if (content) { - const copies = Number.isFinite(singleCopies.value) && singleCopies.value > 0 - ? singleCopies.value : 1 - rows = [{ content, label: singleLabel.value.trim(), copies }] - } - } else { - try { - csvError.value = '' - rows = parseCsv(csvText.value) - if (csvText.value.trim() && !rows.length) { - csvError.value = 'No usable rows found. Every row needs a content value.' - } - } catch (error) { - csvError.value = 'Could not read that CSV: ' + error.message - rows = [] - } + if (!content) return [] + const copies = Number.isFinite(singleCopies.value) && singleCopies.value > 0 + ? singleCopies.value : 1 + return [{ + content, + label: singleLabel.value.trim(), + copies, + back: singleBack.value.trim(), + backlabel: '', + }] } - - const expanded = [] - for (const row of rows) { - for (let i = 0; i < row.copies; i++) { - expanded.push({ content: row.content, label: row.label }) + try { + csvError.value = '' + const parsed = parseCsv(csvText.value) + if (csvText.value.trim() && !parsed.length) { + csvError.value = 'No usable rows found. Every row needs a content value.' } + return parsed + } catch (error) { + csvError.value = 'Could not read that CSV: ' + error.message + return [] } - return expanded }) -const truncated = computed(() => - Math.max(0, allLabels.value.length - MAX_LABELS)) +const built = computed(() => buildPages(rows.value, { + back: { + enabled: backenabled.value, + source: backsource.value, + text: backtext.value.trim(), + order: backorder.value, + }, + max: MAX_LABELS, +})) -const labels = computed(() => allLabels.value.slice(0, MAX_LABELS)) +const pages = computed(() => built.value.pages) +const cardCount = computed(() => built.value.fronts.length) +const truncated = computed(() => built.value.dropped) -// Printing one test label prints the first only; the preview follows so what -// you see is what comes out. -const visibleLabels = computed(() => - testMode.value ? labels.value.slice(0, 1) : labels.value) +// Printing one test label prints the first card only - both of its sides when +// there is a back, because a back that lands upside down is the whole reason +// to print one first. +const visiblePages = computed(() => { + if (!testMode.value) return pages.value + const { fronts, backs } = built.value + return [fronts[0], backenabled.value ? backs[0] : null].filter(Boolean) +}) + +function layoutFor(page) { + return page.side === 'back' ? backlayout.value : layout.value +} // --- fit advice ------------------------------------------------------------- // Modules across the QR at the current content and error correction, quiet // zone excluded (the blank label supplies that). const qrModules = computed(() => { - const content = labels.value[0]?.content + const content = built.value.fronts[0]?.content if (!content || codetype.value !== 'qr') return 0 return qrModuleCount(content, errorcorrection.value) }) const fitAdvice = computed(() => { - const fit = qrFit(labels.value[0]?.content, { + const fit = qrFit(built.value.fronts[0]?.content, { sizeInches: codesize.value, dpi: dpi.value, errorCorrection: errorcorrection.value, }) if (!fit || codetype.value !== 'qr') return '' @@ -364,14 +430,14 @@ async function renderAll() { const token = ++renderToken const failed = [] const next = [] - for (const row of labels.value) { + for (const page of pages.value) { try { - next.push(await renderOne(row)) + next.push(await renderOne(page)) } catch { // CODE128 rejects some characters; an unencodable row must be named, // not silently dropped to a blank sticker. next.push('') - failed.push(row.content.slice(0, 20)) + failed.push(page.content.slice(0, 20)) } if (token !== renderToken) return } @@ -383,6 +449,12 @@ async function renderAll() { // @page cannot read a scoped style, so the sizing lives in custom properties on // the root element. Chromium honors var() in @page size. +const VAR_NAMES = [ + '--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh', + '--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign', + '--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y', +] + function applyVars() { const root = document.documentElement root.style.setProperty('--tool-label-w', labelwidth.value + 'in') @@ -391,21 +463,26 @@ function applyVars() { root.style.setProperty('--tool-code', codesize.value + 'in') root.style.setProperty('--tool-barh', barheight.value + 'in') // Both symbologies need clear space: a QR wants a quiet zone around it, and - // CODE128 wants one at each end (10x the narrow bar). It doubles as the gap - // between the code and the label text, so zeroing it for barcodes let the - // bars run into the caption. + // CODE128 wants one at each end (10x the narrow bar). It is the code's own + // margin and nothing else's - the space between the code and the caption is + // --tool-gap, so a tight caption no longer means shaving the quiet zone. root.style.setProperty('--tool-quiet', quiet.value + 'in') + root.style.setProperty('--tool-gap', gap.value + 'in') root.style.setProperty('--tool-font', labelfont.value + 'pt') + root.style.setProperty('--tool-align', align.value) + root.style.setProperty('--tool-valign', valign.value) + // Content offset moves what is printed WITHIN the label. Media offset moves + // the label box itself on the stock. They are different fixes: the first + // composes a label, the second corrects a printer whose origin is off. + root.style.setProperty('--tool-content-x', contentx.value + 'in') + root.style.setProperty('--tool-content-y', contenty.value + 'in') root.style.setProperty('--tool-nudge-x', nudgex.value + 'in') root.style.setProperty('--tool-nudge-y', nudgey.value + 'in') } function clearVars() { const root = document.documentElement - for (const name of ['--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', - '--tool-barh', '--tool-quiet', '--tool-font', '--tool-nudge-x', '--tool-nudge-y']) { - root.style.removeProperty(name) - } + for (const name of VAR_NAMES) root.style.removeProperty(name) } function applyPreset() { @@ -417,6 +494,7 @@ function applyPreset() { padding.value = chosen.padding quiet.value = chosen.quiet labelfont.value = chosen.labelfont + if (chosen.gap !== undefined) gap.value = chosen.gap } function resetSettings() { @@ -424,6 +502,10 @@ function resetSettings() { applyPreset() layout.value = 'side' barheight.value = 0.3 + align.value = 'center' + valign.value = 'center' + contentx.value = 0 + contenty.value = 0 nudgex.value = 0 nudgey.value = 0 } @@ -440,11 +522,20 @@ function onCsvFile(event) { } function downloadTemplate() { - const csv = [ - 'content,label,copies', - 'https://example.com/asset/1,Bay 12 press,1', - 'WJ-LF-0001,Line 1 fixture,2', - ].join('\n') + // The template carries the back columns only when they would be read, so a + // one-sided run is not handed a CSV with two columns it must ignore. + const perRowBack = backenabled.value && backsource.value === 'column' + const csv = perRowBack + ? [ + 'content,label,copies,back,backlabel', + 'https://example.com/asset/1,Bay 12 press,1,https://example.com/asset/1/history,Service history', + 'WJ-LF-0001,Line 1 fixture,2,WJ-LF-0001-B,Return to Tool Crib', + ].join('\n') + : [ + 'content,label,copies', + 'https://example.com/asset/1,Bay 12 press,1', + 'WJ-LF-0001,Line 1 fixture,2', + ].join('\n') const link = document.createElement('a') link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })) link.download = 'code_labels_template.csv' @@ -491,11 +582,11 @@ watch(SETTING_KEYS.map(key => settingRefs[key]), () => { saveSettings() }) -watch([labels, codetype, errorcorrection], renderAll) +watch([pages, codetype, errorcorrection], renderAll) diff --git a/frontend/src/composables/listQuery.js b/frontend/src/composables/listQuery.js index cee61558..205dba43 100644 --- a/frontend/src/composables/listQuery.js +++ b/frontend/src/composables/listQuery.js @@ -1,5 +1,6 @@ import { ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { useSearchSuggestion } from './searchSuggestion' // Keep a list page's current page and search term in the URL query so the // browser Back button restores them. Without this a list keeps page in local @@ -16,6 +17,18 @@ import { useRoute, useRouter } from 'vue-router' // deep link) so the list reloads at the restored page. // extraKeys optional extra query keys to persist (e.g. ['typeid']); each // gets a ref exposed under the returned `extras` object. +// +// SPELLING SUGGESTIONS come along for free, because this composable already +// owns the search term. A list reports how many rows it got and gets back a +// suggestion to offer when that number is zero: +// +// const { search, suggestion, reportCount } = useListQuery({ onChange: load }) +// // at the end of load(): reportCount(rows.value.length) +// // in the empty state: +// +// A list that never calls reportCount simply never offers one, which is what +// every list did before. export function useListQuery(options = {}) { const route = useRoute() const router = useRouter() @@ -98,5 +111,17 @@ export function useListQuery(options = {}) { if (changed) onChange() }) - return { page, search, extras, setPage, setSearch, setExtra } + const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion() + + // Called by the list after a fetch. Zero rows for a non-empty search is the + // only moment a suggestion is worth asking for. + function reportCount(count) { + if (count > 0 || !search.value) clearSuggestion() + else checkSuggestion(search.value) + } + + return { + page, search, extras, setPage, setSearch, setExtra, + suggestion, reportCount, + } } diff --git a/frontend/src/composables/searchSuggestion.js b/frontend/src/composables/searchSuggestion.js new file mode 100644 index 00000000..9242c9a7 --- /dev/null +++ b/frontend/src/composables/searchSuggestion.js @@ -0,0 +1,56 @@ +import { ref } from 'vue' +import api from '../api' + +/** + * "Did you mean" for a search that found nothing. + * + * ONE composable for every list in the product. Thirty routes take a search + * parameter across a dozen files, so carrying a suggestion in each of their + * responses would be a large change now and a thing every future plugin has to + * remember. Instead a page that renders no rows asks for a spelling, and a page + * that has not adopted this shows nothing - exactly what it showed before. + * + * Usage in a list view: + * + * const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion() + * // after a fetch: + * items.value.length ? clearSuggestion() : checkSuggestion(searchTerm.value) + * + * and in the template, beside the empty state: + * + * + */ +export function useSearchSuggestion() { + const suggestion = ref(null) + // Guards a slow answer arriving after a newer search has already been typed, + // which would offer a spelling for a query nobody is looking at any more. + let latest = 0 + + function clearSuggestion() { + suggestion.value = null + latest += 1 + } + + async function checkSuggestion(query) { + const term = (query || '').trim() + if (term.length < 2) { + clearSuggestion() + return + } + const ticket = ++latest + try { + const response = await api.get('/search/suggest', { params: { q: term } }) + if (ticket !== latest) return + const offered = response.data?.data?.suggestion || null + // Never offer back what was typed: a suggestion identical to the query + // reads as a broken feature. + suggestion.value = offered && offered !== term.toLowerCase() ? offered : null + } catch (err) { + // A search that found nothing is already the answer. Failing to improve + // on it is not worth an error in front of anyone. + if (ticket === latest) suggestion.value = null + } + } + + return { suggestion, checkSuggestion, clearSuggestion } +} diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue index 48d35d5d..80d262f8 100644 --- a/frontend/src/views/SearchResults.vue +++ b/frontend/src/views/SearchResults.vue @@ -42,6 +42,7 @@
No results found for "{{ query }}" +
@@ -94,12 +95,14 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' import { searchApi, knowledgebaseApi } from '../api' +import SearchSuggestion from '../components/SearchSuggestion.vue' const route = useRoute() const router = useRouter() const loading = ref(false) const results = ref([]) +const suggestion = ref(null) const query = ref('') const searchInput = ref('') const activeFilter = ref('all') @@ -175,6 +178,7 @@ const filteredResults = computed(() => { async function search(q) { if (!q || q.length < 2) { results.value = [] + suggestion.value = null return } @@ -184,6 +188,10 @@ async function search(q) { try { const response = await searchApi.search(q) const data = response.data.data + // The global endpoint carries the suggestion with the results, so no second + // call is needed here. A list page that has no such key uses the + // useSearchSuggestion composable against /search/suggest instead. + suggestion.value = data?.suggestion || null // Handle ServiceNOW redirect if (data?.redirect?.type === 'servicenow') { @@ -212,6 +220,14 @@ async function search(q) { } } +// Take the offered spelling: put it in the box and search it, so the person +// sees what was searched rather than results for a word they did not type. +function applySuggestion(word) { + searchInput.value = word + suggestion.value = null + performSearch() +} + function performSearch() { const term = searchInput.value.trim() if (!term) return diff --git a/frontend/src/views/vendors/VendorsList.vue b/frontend/src/views/vendors/VendorsList.vue index 734d5516..1b08da3e 100644 --- a/frontend/src/views/vendors/VendorsList.vue +++ b/frontend/src/views/vendors/VendorsList.vue @@ -55,6 +55,7 @@ No vendors found + @@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue' import { useToast } from '../../composables/toast' import { apiError } from '../../utils/apiError' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const toast = useToast() const vendors = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadVendors }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadVendors() +} const totalPages = ref(1) const perPage = ref(20) @@ -232,6 +243,7 @@ async function loadVendors() { const response = await vendorsApi.list(params) vendors.value = response.data.data || [] + reportCount(vendors.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (err) { console.error('Error loading vendors:', err) diff --git a/plugins/computers/frontend/views/PCsList.vue b/plugins/computers/frontend/views/PCsList.vue index d5d8567d..361c2c20 100644 --- a/plugins/computers/frontend/views/PCsList.vue +++ b/plugins/computers/frontend/views/PCsList.vue @@ -66,6 +66,7 @@ No computers found + @@ -91,10 +92,20 @@ import { computersApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { colorStyle } from '@/utils/colorStyle' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const computers = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadComputers() +} const totalPages = ref(1) const perPage = ref(20) @@ -115,6 +126,7 @@ async function loadComputers() { const response = await computersApi.list(params) computers.value = response.data.data || [] + reportCount(computers.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading computers:', error) diff --git a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue index 01c048ca..dcbbfc6f 100644 --- a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue +++ b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue @@ -88,6 +88,7 @@ No articles found + @@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue' import { knowledgebaseApi, applicationsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const loading = ref(true) const articles = ref([]) const topics = ref([]) const stats = ref(null) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadArticles }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadArticles() +} const perPage = ref(20) const totalPages = ref(1) const topicFilter = ref('') @@ -161,6 +172,7 @@ async function loadArticles() { const response = await knowledgebaseApi.list(params) articles.value = response.data.data || [] + reportCount(articles.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading articles:', error) diff --git a/plugins/machines/frontend/views/MachinesList.vue b/plugins/machines/frontend/views/MachinesList.vue index 19732aab..158d45a8 100644 --- a/plugins/machines/frontend/views/MachinesList.vue +++ b/plugins/machines/frontend/views/MachinesList.vue @@ -79,6 +79,7 @@ No machines found + @@ -105,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle" import { machinesApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const machines = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadMachines }) + +// Take the offered spelling: put it in the box and search it, so what was +// searched is what the person sees. +function applySuggestion(word) { + search.value = word + setSearch(word) + // setSearch only syncs the URL, and the watcher that reloads is suppressed + // because search.value already holds the new term - the same trap the global + // search page documents. Load directly. + loadMachines() +} const totalPages = ref(1) const perPage = ref(20) @@ -129,6 +142,7 @@ async function loadMachines() { const response = await machinesApi.list(params) machines.value = response.data.data || [] + reportCount(machines.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading machines:', error) diff --git a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue index f0ea431b..856c8fc1 100644 --- a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue +++ b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue @@ -73,6 +73,7 @@ No measuring tools found + @@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle' import { measuringtoolsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const tools = ref([]) const types = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadTools }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadTools() +} const typeid = ref('') const calibrationstatus = ref('') const totalPages = ref(1) @@ -133,6 +144,7 @@ async function loadTools() { const response = await measuringtoolsApi.list(params) tools.value = response.data.data || [] + reportCount(tools.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (err) { console.error('Error loading measuring tools:', err) diff --git a/plugins/network/frontend/views/NetworkDevicesList.vue b/plugins/network/frontend/views/NetworkDevicesList.vue index 8195a9af..e6e5719f 100644 --- a/plugins/network/frontend/views/NetworkDevicesList.vue +++ b/plugins/network/frontend/views/NetworkDevicesList.vue @@ -97,6 +97,7 @@ No network devices found + @@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle" import { networkApi, vendorsApi, locationsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const devices = ref([]) const deviceTypes = ref([]) const vendors = ref([]) const locations = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadDevices }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadDevices() +} const selectedType = ref(null) const vendorFilter = ref('') const locationFilter = ref('') @@ -220,6 +231,7 @@ async function loadDevices() { const response = await networkApi.list(params) devices.value = response.data.data || [] + reportCount(devices.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading network devices:', error) diff --git a/plugins/printers/frontend/views/PrintersList.vue b/plugins/printers/frontend/views/PrintersList.vue index 36cacdf7..adc92675 100644 --- a/plugins/printers/frontend/views/PrintersList.vue +++ b/plugins/printers/frontend/views/PrintersList.vue @@ -68,6 +68,7 @@ No printers found + @@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue' import { printersApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const printers = ref([]) const printerTypes = ref([]) const typeFilter = ref('') const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadPrinters }) + +// Take the offered spelling: put it in the box and search it, so what was +// searched is what the person sees. +function applySuggestion(word) { + search.value = word + setSearch(word) + // setSearch only syncs the URL, and the watcher that reloads is suppressed + // because search.value already holds the new term - the same trap the global + // search page documents. Load directly. + loadPrinters() +} const totalPages = ref(1) const perPage = ref(20) @@ -125,6 +138,7 @@ async function loadPrinters() { const response = await printersApi.list(params) printers.value = response.data.data || [] + reportCount(printers.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading printers:', error) diff --git a/plugins/tools/frontend/routes.js b/plugins/tools/frontend/routes.js index aa152858..e17ac47e 100644 --- a/plugins/tools/frontend/routes.js +++ b/plugins/tools/frontend/routes.js @@ -3,9 +3,12 @@ * * `default` = AppLayout child routes; `toplevel` = full-screen routes. * - * The index sits inside the app shell. Each tool that PRINTS is toplevel, the - * way every other label page in this repo is: printing from inside AppLayout - * would put the sidebar and header on the label stock. + * The generator sits INSIDE the app shell, unlike the label pages in core. + * Those are toplevel because a print from inside AppLayout would put the + * sidebar on the label stock - which is true unless the print stylesheet says + * otherwise, and style.css now does: the shell is hidden at print time so only + * the labels reach the page. A tool someone spends ten minutes in wants the + * navigation; a one-shot print page does not. */ export default [ { @@ -14,11 +17,8 @@ export default [ component: () => import('./views/ToolsIndex.vue'), meta: { requiresAuth: true, plugin: 'tools' } }, -] - -export const toplevel = [ { - path: '/tools/codes', + path: 'tools/codes', name: 'tools-codes', component: () => import('./views/CodeGenerator.vue'), meta: { requiresAuth: true, plugin: 'tools' } diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue index 9b1cac6c..78fb6a60 100644 --- a/plugins/tools/frontend/views/CodeGenerator.vue +++ b/plugins/tools/frontend/views/CodeGenerator.vue @@ -1,12 +1,12 @@