Fix what the last round of device fixes broke, and two dips it missed

This commit is contained in:
2026-08-21 08:50:00 -04:00
parent 4f0399b8ac
commit 5a58d33623
8 changed files with 517 additions and 26 deletions

View File

@@ -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

View File

@@ -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'"
/>
<!-- Locked while editing, because an asset number is identity and
changing it silently re-keys the record. The exception is a
record that HAS no number: those exist (an update path used to
accept a blank), the server now rejects a blank on save, and
with the field disabled there was no way to supply one - the
row could not be saved at all until it was fixed elsewhere. -->
<small class="form-hint" v-if="assetNumberMissing">
This device has no asset number. Give it one to save.
</small>
<small class="form-hint" v-if="!isEdit && !form.assetnumber && generatedAssetNumber">
Will be created as <code>{{ generatedAssetNumber }}</code>
</small>
@@ -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 || ''

View File

@@ -90,7 +90,7 @@
<th class="collevel">Level</th>
<th>Runs out</th>
<th>Rate</th>
<th title="Cartridge changes detected, over the days of history behind this row">Replacements</th>
<th title="Cartridge changes seen anywhere in the history window, and how long the CURRENT cartridge has been in">Replacements</th>
</tr>
</thead>
<tbody>
@@ -131,10 +131,15 @@
<template v-else>-</template>
</td>
<td class="muted small">
<!-- "2 / 90d" read as a date, a ratio, or a version to
everyone who saw it. Say the unit. -->
<!-- Two DIFFERENT spans, so they must not be joined by a
word that implies one contains the other. Replacements are
counted across the whole history window; basisdays is only
how long the CURRENT cartridge has been in. "2 in 4.0d"
claimed two changes within four days, which is the shape
that reads as broken data - and here it would be the
label lying, not the data. -->
<template v-if="c.basisdays">
{{ c.replacements || 0 }} in {{ c.basisdays }}d
{{ c.replacements || 0 }}, this one {{ c.basisdays }}d
</template>
<template v-else>{{ c.replacements || 0 }}</template>
</td>

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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')
})
})