Fix what the last round of device fixes broke, and two dips it missed
This commit is contained in:
@@ -415,11 +415,13 @@ class ComputersPlugin(BasePlugin):
|
|||||||
partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
|
partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
|
||||||
warnings, deviceid=deviceid)
|
warnings, deviceid=deviceid)
|
||||||
|
|
||||||
# PC -> machine link from the reported machine number.
|
# PC -> machine link from the reported machine number. Only a PC that
|
||||||
if partmarkers:
|
# is NOT a marker PC (None above) takes it: a marker PC reaches its
|
||||||
machinelinks = []
|
# operation through `partof` on the marker, never by claiming it.
|
||||||
else:
|
if partmarkers is None:
|
||||||
machinelinks = self._sync_machine_link(comp, machinenumber, warnings)
|
machinelinks = self._sync_machine_link(comp, machinenumber, warnings)
|
||||||
|
else:
|
||||||
|
machinelinks = []
|
||||||
|
|
||||||
# Printer relationship sync (only when the payload carried printer data).
|
# Printer relationship sync (only when the payload carried printer data).
|
||||||
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
||||||
@@ -444,7 +446,10 @@ class ComputersPlugin(BasePlugin):
|
|||||||
'measuringtoollinkcount': len(measuringtoollinks),
|
'measuringtoollinkcount': len(measuringtoollinks),
|
||||||
'accessprotocols': accessprotocols,
|
'accessprotocols': accessprotocols,
|
||||||
'machinelinks': machinelinks,
|
'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:
|
if not controls or not deviceasset or not pcasset:
|
||||||
return True
|
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(
|
others = AssetRelationship.query.filter(
|
||||||
AssetRelationship.targetassetid == deviceasset.assetid,
|
AssetRelationship.targetassetid == deviceasset.assetid,
|
||||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||||
AssetRelationship.label == label,
|
AssetRelationship.label.in_(ourlabels),
|
||||||
AssetRelationship.isactive.is_(True),
|
AssetRelationship.isactive.is_(True),
|
||||||
AssetRelationship.sourceassetid != pcasset.assetid,
|
AssetRelationship.sourceassetid != pcasset.assetid,
|
||||||
).all()
|
).all()
|
||||||
@@ -923,7 +939,16 @@ class ComputersPlugin(BasePlugin):
|
|||||||
# Only a device that FILES UNDER an operation goes through here.
|
# Only a device that FILES UNDER an operation goes through here.
|
||||||
# A measuring tool is a subordinate device too, but it does not
|
# A measuring tool is a subordinate device too, but it does not
|
||||||
# share a machine number, so it keeps the simpler path.
|
# 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
|
pcasset = comp.asset
|
||||||
controls = RelationshipType.query.filter_by(
|
controls = RelationshipType.query.filter_by(
|
||||||
@@ -998,8 +1023,15 @@ class ComputersPlugin(BasePlugin):
|
|||||||
self._ensure_device_rows(markerasset, spec, pcasset, controls,
|
self._ensure_device_rows(markerasset, spec, pcasset, controls,
|
||||||
label, warnings, active=mayhold)
|
label, warnings, active=mayhold)
|
||||||
elif reuse:
|
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)
|
markerasset = db.session.get(Asset, reuse.targetassetid)
|
||||||
|
reuse.isactive = self._device_incumbents_yield(
|
||||||
|
markerasset, pcasset, label, warnings)
|
||||||
else:
|
else:
|
||||||
coretype = AssetType.query.filter_by(
|
coretype = AssetType.query.filter_by(
|
||||||
assettype=spec['assettype']).first()
|
assettype=spec['assettype']).first()
|
||||||
@@ -1419,8 +1451,11 @@ class ComputersPlugin(BasePlugin):
|
|||||||
tooltype.measuringtooltypeid
|
tooltype.measuringtooltypeid
|
||||||
targetid = adopted.assetid
|
targetid = adopted.assetid
|
||||||
elif reuse:
|
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)
|
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:
|
if toolasset and toolasset.measuringtool and tooltype:
|
||||||
toolasset.measuringtool.measuringtooltypeid = \
|
toolasset.measuringtool.measuringtooltypeid = \
|
||||||
tooltype.measuringtooltypeid
|
tooltype.measuringtooltypeid
|
||||||
|
|||||||
@@ -24,9 +24,18 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
:required="isEdit"
|
:required="isEdit"
|
||||||
:disabled="isEdit"
|
:disabled="isEdit && !assetNumberMissing"
|
||||||
:placeholder="generatedAssetNumber || 'leave blank to generate from the name'"
|
: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">
|
<small class="form-hint" v-if="!isEdit && !form.assetnumber && generatedAssetNumber">
|
||||||
Will be created as <code>{{ generatedAssetNumber }}</code>
|
Will be created as <code>{{ generatedAssetNumber }}</code>
|
||||||
</small>
|
</small>
|
||||||
@@ -393,6 +402,10 @@ const generatedAssetNumber = computed(() => {
|
|||||||
if (name.toUpperCase().startsWith(prefix + '-')) return name
|
if (name.toUpperCase().startsWith(prefix + '-')) return name
|
||||||
return `${prefix}-${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 vendors = ref([])
|
||||||
const models = ref([])
|
const models = ref([])
|
||||||
|
|
||||||
@@ -480,6 +493,9 @@ async function loadDevice() {
|
|||||||
|
|
||||||
// Populate form with existing data
|
// Populate form with existing data
|
||||||
form.value.assetnumber = data.assetnumber || ''
|
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.name = data.name || ''
|
||||||
form.value.serialnumber = data.serialnumber || ''
|
form.value.serialnumber = data.serialnumber || ''
|
||||||
form.value.gaugelabreference = data.gaugelabreference || ''
|
form.value.gaugelabreference = data.gaugelabreference || ''
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
<th class="collevel">Level</th>
|
<th class="collevel">Level</th>
|
||||||
<th>Runs out</th>
|
<th>Runs out</th>
|
||||||
<th>Rate</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -131,10 +131,15 @@
|
|||||||
<template v-else>-</template>
|
<template v-else>-</template>
|
||||||
</td>
|
</td>
|
||||||
<td class="muted small">
|
<td class="muted small">
|
||||||
<!-- "2 / 90d" read as a date, a ratio, or a version to
|
<!-- Two DIFFERENT spans, so they must not be joined by a
|
||||||
everyone who saw it. Say the unit. -->
|
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">
|
<template v-if="c.basisdays">
|
||||||
{{ c.replacements || 0 }} in {{ c.basisdays }}d
|
{{ c.replacements || 0 }}, this one {{ c.basisdays }}d
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ c.replacements || 0 }}</template>
|
<template v-else>{{ c.replacements || 0 }}</template>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ REPLACEMENT_RISE = 10
|
|||||||
# while a phantom swap resets the run and throws the estimate away entirely.
|
# while a phantom swap resets the run and throws the estimate away entirely.
|
||||||
NEW_CARTRIDGE_LEVEL = 80
|
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
|
# Below this many readings a slope is arithmetic, not evidence. Two points
|
||||||
# through a coarse gauge can "prove" any rate at all.
|
# through a coarse gauge can "prove" any rate at all.
|
||||||
MIN_POINTS_FOR_ESTIMATE = 4
|
MIN_POINTS_FOR_ESTIMATE = 4
|
||||||
@@ -92,8 +106,23 @@ def normalise(points):
|
|||||||
return drop_spikes(out)
|
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):
|
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
|
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
|
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
|
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.
|
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
|
ANY LENGTH, not just one reading. At the few-minute polling these items
|
||||||
genuine near-empty reading before a swap (30, 5, 100) does not recover - it
|
often use, a door left open or a supply out of the machine spans several
|
||||||
jumps to full - so it is kept, and the swap after it still counts.
|
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:
|
if len(points) < 3:
|
||||||
return points
|
return points
|
||||||
out = [points[0]]
|
out = [points[0]]
|
||||||
for index in range(1, len(points) - 1):
|
index = 1
|
||||||
|
while index < len(points) - 1:
|
||||||
previous = points[index - 1][1]
|
previous = points[index - 1][1]
|
||||||
current = points[index][1]
|
if (previous - points[index][1]) < rise:
|
||||||
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[index])
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
# 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])
|
out.append(points[-1])
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
150
plugins/tools/frontend/labelPages.js
Normal file
150
plugins/tools/frontend/labelPages.js
Normal 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 }
|
||||||
|
}
|
||||||
113
plugins/tools/frontend/labelPages.spec.js
Normal file
113
plugins/tools/frontend/labelPages.spec.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.
|
# 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 Asset.query.filter_by(assetnumber='FMARK500-PARTMARKER').first() is None
|
||||||
assert _controlled('FMARK500', 'collector:partmarker') == []
|
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):
|
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')
|
machinenumber='0617', deviceid='PLAIN-MACHINE')
|
||||||
assert resp.status_code in (200, 201)
|
assert resp.status_code in (200, 201)
|
||||||
assert _controlled('FMARK600', 'collector:partmarker') == []
|
assert _controlled('FMARK600', 'collector:partmarker') == []
|
||||||
|
assert _controlled('FMARK600', 'collector:machine') == []
|
||||||
assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None
|
assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None
|
||||||
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
|
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
|
||||||
assert 'PLAIN-MACHINE' in 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')
|
deviceid='MT-9003', measuringtoolid='MT-9004')
|
||||||
assert resp.status_code in (200, 201)
|
assert resp.status_code in (200, 201)
|
||||||
assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']
|
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
|
||||||
|
|||||||
@@ -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)]
|
levels = [100, 90, 80] + [80 - i * 0.2 for i in range(1, 28)]
|
||||||
rate = burn_rate(normalise(series(levels)))
|
rate = burn_rate(normalise(series(levels)))
|
||||||
assert rate is not None
|
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))
|
detail = analyse(series(levels))
|
||||||
assert detail['rateunstable'] is True
|
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]))
|
detail = analyse(series([84, 83, 82, 81, 80, 79]))
|
||||||
assert detail['burnrateperday'] == 1.0
|
assert detail['burnrateperday'] == 1.0
|
||||||
assert detail['daysleft'] == 79
|
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) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user