collector: adopt an instrument before minting one, and stop the 500 loops
Minting derived a measuring tool's asset number from the HOSTNAME, so a permanent instrument inherited the identity of whichever PC drove it that week: replace the PC and either the number lies or a second tool appears for the same physical unit. And because idempotency was keyed on the collector's own label, it could not see a tool it had not created - on prod that left 43 legacy MT-#### tools shadowed by minted <HOST>-CMM twins, three records deep in places. Resolution order is now most-stable-identity-first: the instrument named by measuringtool-id.txt, then a prior collector link, then a tool this PC already controls that somebody else created, then the reported machine number, and only then mint. What minting produces should be read as a placeholder until a real identifier is recorded. Three separate 500 loops came out of the same mistake, looking a relationship up by LABEL when assetrelationships is unique on (source, target, type): - On a CMM the instrument IS the reported bay, so the machine sync has already made a row for that exact triple - and it finds its own rows by that label. Relabelling hid the link, so the next cycle built a second row for the same triple and MySQL rejected it: 200 once, then 500 forever. The machine link is now recognised and left alone; adoption only needs the identity. - A part-marker PC hit it twice over, once on its partof row and once because the marker's asset number is derived from the PC and could already be taken. Both are get-or-create on the triple now, and an existing asset of that number is adopted rather than duplicated. A named instrument also supersedes a minted twin properly: the stale link is archived by TARGET, not by object identity, which is what left a PC reading as the controller of two instruments. Reported identifiers are matched exactly rather than with ilike. They arrive from a text file on a shopfloor PC, and ilike reads _ and % as wildcards, so MT-600_ adopted MT-6001 and a bare % adopted whatever active asset came first. A named id that is not a measuring tool is refused with a warning rather than linked as one.
This commit is contained in:
@@ -106,6 +106,13 @@ 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.
|
||||
'measuringtoolid': {'type': 'string'},
|
||||
'pctype': {'type': 'string'},
|
||||
'pcsubtype': {'type': 'string'},
|
||||
'serialnumber': {'type': 'string'},
|
||||
@@ -187,7 +194,7 @@ class ComputersPlugin(BasePlugin):
|
||||
"""Whether the PC-to-machine alerts may be sent.
|
||||
|
||||
Off by default, deliberately. A site may legitimately run several PCs
|
||||
on one machine number - part markers do at West Jefferson - and there
|
||||
on one machine number - part markers do at the reference site - and there
|
||||
both the handover and the contested case fire on normal, correct data,
|
||||
which is noise rather than news. Turn it on at a site where a machine
|
||||
number means exactly one PC.
|
||||
@@ -370,7 +377,9 @@ class ComputersPlugin(BasePlugin):
|
||||
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
|
||||
# get an attached MeasuringTool asset auto-created and linked.
|
||||
measuringtoollinks = self._sync_measuringtool_link(
|
||||
comp.asset, pctype, hostname, warnings)
|
||||
comp.asset, pctype, hostname, warnings,
|
||||
measuringtoolid=payload.get('measuringtoolid'),
|
||||
machinenumber=machinenumber)
|
||||
|
||||
db.session.commit()
|
||||
return {
|
||||
@@ -619,9 +628,7 @@ class ComputersPlugin(BasePlugin):
|
||||
'run flask seed reference-data')
|
||||
return []
|
||||
|
||||
machine = Asset.query.filter(
|
||||
Asset.assetnumber.ilike(machinenumber),
|
||||
Asset.isactive.is_(True)).first()
|
||||
machine = self._asset_by_number(machinenumber)
|
||||
if not machine:
|
||||
# Reported a machine ShopDB does not know. Warn rather than invent
|
||||
# an asset: a mistyped number would create a machine nobody can
|
||||
@@ -791,22 +798,48 @@ class ComputersPlugin(BasePlugin):
|
||||
db.session.flush()
|
||||
|
||||
hostname = comp.hostname
|
||||
markerasset = Asset(
|
||||
assetnumber='{}-{}'.format(
|
||||
pcasset.assetnumber or hostname, spec['suffix']),
|
||||
name='{} ({})'.format(spec['typename'], hostname),
|
||||
assettypeid=coretype.assettypeid,
|
||||
statusid=1)
|
||||
db.session.add(markerasset)
|
||||
db.session.flush()
|
||||
db.session.add(Machine(assetid=markerasset.assetid,
|
||||
machinetypeid=devicetype.machinetypeid))
|
||||
db.session.add(AssetRelationship(
|
||||
assetnumber = '{}-{}'.format(
|
||||
pcasset.assetnumber or hostname, spec['suffix'])
|
||||
|
||||
# ADOPT AN EXISTING ASSET OF THAT NUMBER before minting. The number
|
||||
# is derived from the PC, so it is entirely predictable and may
|
||||
# already exist - left by an earlier run whose link was archived, or
|
||||
# created by a person. assetnumber is unique, so inserting a second
|
||||
# one raised and the whole report 500'd, on every cycle, forever.
|
||||
#
|
||||
# Deliberately NOT filtered on isactive: an inactive asset still
|
||||
# holds the number, and it is the constraint that decides this.
|
||||
markerasset = Asset.query.filter(
|
||||
db.func.lower(Asset.assetnumber) == assetnumber.lower()).first()
|
||||
if markerasset is None:
|
||||
markerasset = Asset(
|
||||
assetnumber=assetnumber,
|
||||
name='{} ({})'.format(spec['typename'], hostname),
|
||||
assettypeid=coretype.assettypeid,
|
||||
statusid=1)
|
||||
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,
|
||||
label=label,
|
||||
isactive=True))
|
||||
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))
|
||||
|
||||
# One marker per PC: archive any other collector marker link.
|
||||
for rel in existing:
|
||||
@@ -847,9 +880,7 @@ class ComputersPlugin(BasePlugin):
|
||||
'run flask seed reference-data')
|
||||
return None
|
||||
|
||||
operation = Asset.query.filter(
|
||||
Asset.assetnumber.ilike(machinenumber),
|
||||
Asset.isactive.is_(True)).first()
|
||||
operation = self._asset_by_number(machinenumber)
|
||||
if not operation:
|
||||
warnings.append(
|
||||
'no asset for machine number {!r}; marker not filed under an '
|
||||
@@ -865,19 +896,34 @@ class ComputersPlugin(BasePlugin):
|
||||
machinenumber))
|
||||
return None
|
||||
|
||||
links = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == markerasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == partof.relationshiptypeid,
|
||||
AssetRelationship.label == label,
|
||||
).all()
|
||||
found = None
|
||||
for rel in links:
|
||||
if rel.targetassetid == operation.assetid:
|
||||
rel.isactive = True
|
||||
found = rel
|
||||
else:
|
||||
# Our own rows first: a marker that moved to another operation has its
|
||||
# old membership archived, never deleted, so where it used to live stays
|
||||
# answerable.
|
||||
for rel in AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == markerasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == partof.relationshiptypeid,
|
||||
AssetRelationship.label == label).all():
|
||||
if rel.targetassetid != operation.assetid and rel.isactive:
|
||||
rel.isactive = False
|
||||
if found is None:
|
||||
|
||||
# Then get-or-create KEYED ON THE UNIQUE TRIPLE, not on the label.
|
||||
# assetrelationships is unique on (source, target, type), so a row made
|
||||
# by hand or by the legacy import - carrying a different label, or none
|
||||
# at all - was invisible to the label-filtered lookup this used to do,
|
||||
# and the insert then violated that constraint. The PC reported 200 on
|
||||
# the cycle that minted the marker and 500 on every cycle after.
|
||||
found = AssetRelationship.query.filter_by(
|
||||
sourceassetid=markerasset.assetid,
|
||||
targetassetid=operation.assetid,
|
||||
relationshiptypeid=partof.relationshiptypeid).first()
|
||||
if found is not None:
|
||||
found.isactive = True
|
||||
if not found.label:
|
||||
# Unlabelled means the import or a person made it. Stamping ours
|
||||
# is what makes the next cycle reuse it. A row carrying somebody
|
||||
# else's label is left as theirs.
|
||||
found.label = label
|
||||
else:
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=markerasset.assetid,
|
||||
targetassetid=operation.assetid,
|
||||
@@ -1022,18 +1068,39 @@ class ComputersPlugin(BasePlugin):
|
||||
'PC-superseded alert failed for machine %s',
|
||||
getattr(machine, 'assetnumber', '?'))
|
||||
|
||||
def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings):
|
||||
"""Auto-create + link the MeasuringTool a metrology PC drives.
|
||||
def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings,
|
||||
measuringtoolid=None, machinenumber=None):
|
||||
"""Link the MeasuringTool a metrology PC drives. ADOPT before minting.
|
||||
|
||||
A CMM / Keyence / Genspect / wax-and-trace imaging pc-type means the
|
||||
shopfloor PC controls an attached measuring instrument. This creates
|
||||
that instrument once as a MeasuringTool asset and a directional
|
||||
PC->tool 'controls' relationship, tagged MEASURINGTOOL_LINK_ORIGIN so
|
||||
it is idempotent and self-archiving. The PC's own ComputerType is left
|
||||
alone (it stays a shopfloor PC). A non-metrology pc-type archives any
|
||||
collector-created tool link (e.g. a PC re-imaged to another type) but
|
||||
never deletes the tool asset, which may carry calibration history.
|
||||
Returns the desired-link list.
|
||||
A CMM / Keyence / Genspect / wax-and-trace pc-type means the PC controls
|
||||
an attached instrument. This links it with a directional PC->tool
|
||||
'controls' relationship tagged MEASURINGTOOL_LINK_ORIGIN.
|
||||
|
||||
RESOLUTION ORDER, most stable identity first:
|
||||
|
||||
1. measuringtoolid - the instrument named by the enrollment file
|
||||
measuringtool-id.txt. Explicit, survives a PC swap, and it is what
|
||||
a Keyence or Genspect bay has instead of a bay id.
|
||||
2. a prior collector link on this PC (reactivate + retype).
|
||||
3. an EXISTING measuring tool this PC already controls that the
|
||||
collector did not create - a legacy MT-#### row, or one somebody
|
||||
made by hand. Adopting stamps the label so it is ours from then on.
|
||||
4. the reported machine number, when it resolves to a measuring tool.
|
||||
This is the CMM case: cmmid.txt already reports CMM4.
|
||||
5. mint, only when none of the above found anything.
|
||||
|
||||
WHY THIS ORDER. Minting derives an asset number from the HOSTNAME, so a
|
||||
permanent instrument inherited the identity of whichever PC drove it that
|
||||
week - replace the PC and either the number lies or a second tool appears
|
||||
for the same physical unit. It also could not see a tool it had not
|
||||
created itself, so on prod 43 legacy MT-#### tools were shadowed by
|
||||
minted <HOST>-CMM twins, three records deep in places. Minting is now the
|
||||
last resort, and what it produces should be treated as a placeholder
|
||||
until a real identifier is recorded.
|
||||
|
||||
A non-metrology pc-type archives any collector-created tool link (a PC
|
||||
re-imaged to another type) but never deletes the tool asset, which may
|
||||
carry calibration history. Returns the desired-link list.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
from .pctypemap import metrology_tool_for
|
||||
@@ -1073,11 +1140,75 @@ class ComputersPlugin(BasePlugin):
|
||||
typename, typedescription = tool_spec
|
||||
tooltype = self._ensure_measuringtool_type(typename, typedescription)
|
||||
|
||||
# Reuse any prior collector link (reactivate + retype) before creating,
|
||||
# so a re-metrology PC never duplicates the tool asset.
|
||||
# --- 1. an explicitly named instrument wins over everything ---------
|
||||
adopted = None
|
||||
named = (measuringtoolid or '').strip()
|
||||
if named:
|
||||
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.
|
||||
warnings.append(
|
||||
'no asset for measuring tool {!r}; not linked'.format(named))
|
||||
elif 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
|
||||
# as an instrument everywhere downstream, on a row the machine
|
||||
# sync also owns. Refuse and say which asset it hit.
|
||||
warnings.append(
|
||||
'asset {!r} is not a measuring tool; not linked'.format(
|
||||
candidate.assetnumber))
|
||||
else:
|
||||
adopted = candidate
|
||||
|
||||
# --- 2. a prior collector link (reactivate + retype) -----------------
|
||||
reuse = next((rel for rel in existing if rel.isactive), None) \
|
||||
or (existing[0] if existing else None)
|
||||
if reuse:
|
||||
|
||||
# --- 3/4. adopt a tool this PC already controls that we did not make --
|
||||
# Ordered after the collector's own link so a settled PC keeps what it
|
||||
# has, and before minting so an existing instrument is never twinned.
|
||||
if adopted is None and reuse is None:
|
||||
adopted = self._adoptable_measuringtool(
|
||||
pcasset, controls, machinenumber)
|
||||
|
||||
if adopted is not None:
|
||||
# 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
|
||||
# triple, so inserting would raise.
|
||||
claimed = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=adopted.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).first()
|
||||
if claimed is not None and claimed.label == MACHINE_LINK_ORIGIN:
|
||||
# LEAVE IT ALONE. On a CMM the instrument IS the reported bay,
|
||||
# so the machine sync has already made the row for this exact
|
||||
# triple - and it finds its own rows BY that label. Relabelling
|
||||
# hid the link from it, so the next cycle built a second row for
|
||||
# the same triple and MySQL rejected it: the PC reported 200,
|
||||
# then 500 forever after. The row's lifecycle (including staying
|
||||
# dormant while a bay is contested) belongs to the machine sync;
|
||||
# adoption only needs the identity, which it has.
|
||||
pass
|
||||
elif claimed is not None:
|
||||
# Relabelling is what adoption MEANS for any other row: the link
|
||||
# is ours from here, so a later cycle reuses it instead of
|
||||
# minting.
|
||||
claimed.label = MEASURINGTOOL_LINK_ORIGIN
|
||||
claimed.isactive = True
|
||||
else:
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=adopted.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MEASURINGTOOL_LINK_ORIGIN))
|
||||
if adopted.measuringtool and tooltype:
|
||||
adopted.measuringtool.measuringtooltypeid = \
|
||||
tooltype.measuringtooltypeid
|
||||
targetid = adopted.assetid
|
||||
elif reuse:
|
||||
reuse.isactive = True
|
||||
toolasset = db.session.get(Asset, reuse.targetassetid)
|
||||
if toolasset and toolasset.measuringtool and tooltype:
|
||||
@@ -1111,14 +1242,96 @@ class ComputersPlugin(BasePlugin):
|
||||
label=MEASURINGTOOL_LINK_ORIGIN))
|
||||
targetid = toolasset.assetid
|
||||
|
||||
# Only one tool link is desired; archive any other collector rows.
|
||||
# Only one tool link is desired; archive any other collector row.
|
||||
#
|
||||
# Keyed on the TARGET, not on object identity. `rel is not reuse` spared
|
||||
# the row reuse pointed at even when adoption had just resolved a
|
||||
# different instrument - so a bay whose measuringtool-id.txt named the
|
||||
# real tool kept its minted twin link alive alongside the adopted one,
|
||||
# and the PC read as controlling two instruments. That is the exact
|
||||
# duplicate this whole adoption path exists to remove.
|
||||
for rel in existing:
|
||||
if rel is not reuse and rel.isactive:
|
||||
if rel.targetassetid != targetid and rel.isactive:
|
||||
rel.isactive = False
|
||||
|
||||
return [{'assetid': targetid, 'relationshiptype': 'controls',
|
||||
'measuringtooltype': typename}]
|
||||
|
||||
@staticmethod
|
||||
def _asset_by_number(assetnumber):
|
||||
"""The active asset with exactly this asset number, case-insensitively.
|
||||
|
||||
NOT `ilike`. Every identifier resolved here arrives from a file on a
|
||||
shopfloor PC - cmmid.txt, measuringtool-id.txt, the reported machine
|
||||
number - and ilike reads `_` and `%` in that value as WILDCARDS. A
|
||||
reported '%' matched whatever active asset happened to come first, and
|
||||
'MT-600_' matched MT-6001: an identifier silently resolving to an asset
|
||||
it does not name, then linked to as though it did.
|
||||
"""
|
||||
from shopdb.api import Asset
|
||||
number = (assetnumber or '').strip()
|
||||
if not number:
|
||||
return None
|
||||
return Asset.query.filter(
|
||||
db.func.lower(Asset.assetnumber) == number.lower(),
|
||||
Asset.isactive.is_(True)).first()
|
||||
|
||||
def _measuringtool_assetids(self):
|
||||
"""Asset ids that really are measuring tools.
|
||||
|
||||
Empty when the measuringtools plugin is not installed, which makes both
|
||||
callers fall through rather than guess: an asset number alone does not
|
||||
say what kind of thing it names.
|
||||
"""
|
||||
try:
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
except ImportError:
|
||||
return set()
|
||||
return {row.assetid for row in
|
||||
MeasuringTool.query.with_entities(MeasuringTool.assetid)}
|
||||
|
||||
def _adoptable_measuringtool(self, pcasset, controls, machinenumber):
|
||||
"""A measuring tool this PC already controls that we did not create.
|
||||
|
||||
Two sources, in order:
|
||||
|
||||
a. any ACTIVE controls link from this PC to a measuring_tool asset
|
||||
that carries a different label - the legacy import's MT-#### rows,
|
||||
or one somebody added by hand. These were invisible to the
|
||||
collector, which keyed idempotency on its OWN label and so minted a
|
||||
twin for the same physical instrument on every fresh PC.
|
||||
b. the reported machine number, when it names a measuring tool. That
|
||||
is the CMM case: cmmid.txt reports CMM4, which already resolves to
|
||||
a real asset through the machine-link path.
|
||||
|
||||
Returns the Asset to adopt, or None. Never creates anything.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, Asset
|
||||
|
||||
toolassetids = self._measuringtool_assetids()
|
||||
if not toolassetids:
|
||||
return None
|
||||
|
||||
# a. already linked, by someone else
|
||||
for rel in (AssetRelationship.query
|
||||
.filter(AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
AssetRelationship.relationshiptypeid
|
||||
== controls.relationshiptypeid,
|
||||
AssetRelationship.isactive.is_(True))
|
||||
.order_by(AssetRelationship.relationshipid.asc()).all()):
|
||||
if rel.label == MEASURINGTOOL_LINK_ORIGIN:
|
||||
continue
|
||||
if rel.targetassetid in toolassetids:
|
||||
return db.session.get(Asset, rel.targetassetid)
|
||||
|
||||
# b. the reported machine number naming a tool
|
||||
number = (machinenumber or '').strip()
|
||||
if number:
|
||||
candidate = self._asset_by_number(number)
|
||||
if candidate is not None and candidate.assetid in toolassetids:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _ensure_measuringtool_type(self, name, description):
|
||||
"""Find or create a MeasuringToolType (metrology types are not in the
|
||||
measuringtools starter seed)."""
|
||||
|
||||
Reference in New Issue
Block a user