drawings: VISUAL's rev is the source of truth; attach it when M-Files holds it Effective or Retired
ci / frontend (pull_request) Successful in 43s
ci / server (pull_request) Successful in 11m3s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 3m52s

When M-Files' Effective rev differs from the rev VISUAL calls, the drawing
job looks that rev up (MFilesKit DrawingLookup.revision). If M-Files has
it as Effective or Retired, the job copies it into drawing_revisions and
attaches it to the draft with no flag and no Clerical approval, so the
draft can auto-release. Past draft, already attached or with a hand
drawing: unchanged, no flag and no change order.

Any other status, or no document at that rev, still flags rev_mismatch
with a note saying why. Packet Station only ever attaches Effective or
Retired drawings. rev_approve stays for that case only.

This replaces the "Use rev V as VISUAL calls it" choice from #46: the
use=called path in rev-approve and its button are removed. A packet that
already kept the rev that way keeps it, since that rev is now used anyway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 16:05:57 -06:00
co-authored by Claude Opus 5.5
parent c5bf48ac50
commit ca407a2db3
16 changed files with 292 additions and 250 deletions
@@ -4,9 +4,11 @@ flag, stage or open a change order on the packets that use them.
Per run: the queue (drawings/queue.py) cut to `drawing_budget`; per drawing one `Lookup.status`, a fetch when a
released file exists (drawings/fetch.py downloads only when its M-Files identity changed), the `drawing_lookups`
row, then `decide` for every live packet that uses the drawing (its own drawing, or the target of its pending feed
change order), applied through `feed.writes.system_write`. A busy packet is left for the next run. On a draft
flagged rev_mismatch, the rev VISUAL calls is looked up too (`Lookup.revision`) and copied into assets when M-Files
still holds it (Retired, say), so Clerical can choose it or the Effective rev.
change order), applied through `feed.writes.system_write`. A busy packet is left for the next run.
VISUAL is the source of truth for the rev. When M-Files' Effective rev is not the rev VISUAL calls, that rev is
looked up too (`Lookup.revision`). If M-Files has it as Effective or Retired, it is copied into assets and attached
to the draft with no flag. Any other status (or no document at that rev) leaves the packet flagged rev_mismatch.
Never writes to M-Files.
"""
@@ -127,7 +129,7 @@ def _drawing(ctx: JobContext, lk: Lookup, item: queue.QueueItem, counts: Counts,
vrev_seen = item.visual_rev if item.co_id is None else own_rev(conn, item.dwg)
save_lookup(conn, item, found, fetched, now, fetch_failed=fetch_failed, visual_rev_seen=vrev_seen)
scope = [str(d) for d in (getattr(ctx.feed_settings, "departments", None) or [])]
called: dict[int, dict[str, Any] | None] = {} # VISUAL rev -> what M-Files holds at that rev, this drawing
called: dict[int, CalledCopy | None] = {} # VISUAL rev -> what M-Files holds at that rev, this drawing
for pid, relations in sharing(conn, item.dwg, scope):
_packet(ctx, lk, item.dwg, found, fetched, pid, relations, counts, called)
@@ -239,12 +241,27 @@ def save_revision(conn: Any, dwg: str, rf: Found, fetched: FetchResult, now: dat
)
# The M-Files statuses a rev VISUAL calls may be attached at. Anything else is never attached.
ATTACHABLE = frozenset({"effective", "retired"})
@dataclass(frozen=True)
class CalledCopy:
"""The rev VISUAL calls as M-Files holds it. `record` goes on the packet as `visual.called`: {rev, controlled,
state, name, asset_id, sha256, pages} for a copy, {rev, missing: true} when M-Files has no document at that rev,
{rev, controlled, state, refused: true} when its status may not be attached. `found` and `fetched` are set only
for a copy."""
record: dict[str, Any]
found: Found | None = None
fetched: FetchResult | None = None
def called_rev(
ctx: JobContext, lk: Lookup, dwg: str, visual_rev: str, memo: dict[int, dict[str, Any] | None], counts: Counts
) -> dict[str, Any] | None:
"""The rev VISUAL calls, as M-Files holds it: {rev, controlled, state, name, asset_id, sha256, pages}, or
{rev, missing: true} when M-Files has no document at that rev. None when it could not be read this run (the
packet keeps what it had) or the VISUAL rev is not a number."""
ctx: JobContext, lk: Lookup, dwg: str, visual_rev: str, memo: dict[int, CalledCopy | None], counts: Counts
) -> CalledCopy | None:
"""The rev VISUAL calls, as M-Files holds it, copied into assets when its status is Effective or Retired. None when
it could not be read this run (the packet keeps what it had) or the VISUAL rev is not a number."""
v = D.as_int(visual_rev)
if v is None:
return None
@@ -258,7 +275,10 @@ def called_rev(
ctx.error(f"drawing {dwg}: rev {v}: {e}")
return None
if rf is None:
memo[v] = {"rev": str(v), "missing": True}
memo[v] = CalledCopy({"rev": str(v), "missing": True})
return memo[v]
if rf.controlled.casefold() not in ATTACHABLE:
memo[v] = CalledCopy({"rev": str(v), "controlled": rf.controlled, "state": rf.state or None, "refused": True})
return memo[v]
try:
fetched = fetch(conn, ctx.settings, lk, rf, dwg=dwg)
@@ -284,7 +304,7 @@ def called_rev(
"reused": fetched.reused,
},
)
memo[v] = {
record = {
"rev": str(v),
"controlled": rf.controlled,
"state": rf.state or None,
@@ -293,6 +313,7 @@ def called_rev(
"sha256": fetched.sha256.hex(),
"pages": fetched.pages,
}
memo[v] = CalledCopy(record, rf, fetched)
return memo[v]
@@ -397,13 +418,17 @@ def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target:
co_waiting = _dwg_rev(waiting)
co_staged = _dwg_rev(staged)
packet_rev = str(row["rev"] or "").strip()
rev_ok = kept_latest = None
rev_ok = None
ro = visual.get("rev_ok")
if isinstance(ro, dict) and ro.get("rev") is not None and str(ro.get("vis_rev") or "").strip() == packet_rev:
if ro.get("use") == "called":
kept_latest = None if ro.get("latest") is None else str(ro["latest"]).strip()
else:
rev_ok = str(ro["rev"]).strip()
# A rev_ok with use = "called" (Clerical kept the rev VISUAL calls, before that became automatic) approves no other
# rev: the rev VISUAL calls is used anyway.
if (
isinstance(ro, dict)
and ro.get("rev") is not None
and ro.get("use") != "called"
and str(ro.get("vis_rev") or "").strip() == packet_rev
):
rev_ok = str(ro["rev"]).strip()
visual_rev = packet_rev if own or co_waiting is None else co_waiting[1]
return D.Target(
dwg=dwg,
@@ -419,7 +444,6 @@ def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target:
co_pending=co is not None,
co_staged_sha=_sha(staged.get("sha256")) if isinstance(staged, dict) else None,
rejected_sha=pf.rejected_sha,
kept_latest=kept_latest,
)
@@ -461,7 +485,7 @@ def _packet(
pid: int,
relations: set[str],
counts: Counts,
called_memo: dict[int, dict[str, Any] | None],
called_memo: dict[int, CalledCopy | None],
) -> None:
conn = ctx.conn
pf = facts(conn, pid)
@@ -470,7 +494,14 @@ def _packet(
own = "own" in relations
t = target_for(dwg, pf, own=own)
same_file = fetched is not None and fetched.sha256 in pf.attached_shas
d = D.decide(t, found, same_file=same_file, fetched_sha=fetched.sha256 if fetched else None)
cc: CalledCopy | None = None
dc: D.Called | None = None
v = D.as_int(t.visual_rev)
if own and found.ok and v is not None and v != found.rev:
cc = called_rev(ctx, lk, dwg, t.visual_rev, called_memo, counts)
if cc is not None and cc.found is not None and cc.fetched is not None:
dc = D.Called(v, cc.found.controlled, cc.fetched.sha256 in pf.attached_shas)
d = D.decide(t, found, same_file=same_file, fetched_sha=fetched.sha256 if fetched else None, called=dc)
if not own and d.kind != D.STAGE_FOR_CO:
return # only the CO's target drawing: nothing to do until it can be staged
if d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO) and fetched is None:
@@ -486,18 +517,17 @@ def _packet(
if not _same_appr(visual.get("appr"), appr):
patch["appr"] = appr
if found.ok:
confirmed = D.rev_confirmed(t, found)
confirmed = D.rev_confirmed(t, found, dc)
if bool(visual.get("rev_confirmed")) != confirmed:
patch["rev_confirmed"] = confirmed
if d.kind == D.REV_MISMATCH and row["status"] == "draft":
called = called_rev(ctx, lk, dwg, t.visual_rev, called_memo, counts)
if called is not None and visual.get("called") != called:
patch["called"] = called
elif visual.get("called") is not None:
if cc is not None:
if visual.get("called") != cc.record:
patch["called"] = cc.record
elif visual.get("called") is not None and (v is None or v == found.rev):
patch["called"] = None
state_after = d.drawing_state if own else str(row["drawing_state"])
material = (
d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO)
d.kind in (D.ATTACH, D.ATTACH_CALLED, D.OPEN_CO, D.STAGE_FOR_CO)
or flags_after != flags
or state_after != row["drawing_state"]
or bool(patch)
@@ -508,20 +538,28 @@ def _packet(
action = A.VIS_UPDATE_DRAFT if row["status"] == "draft" else A.VIS_OPEN_CO
label, note = "Drawing status from M-Files", d.note or None
called_now = patch.get("called") or {}
if note and called_now.get("controlled"):
note = f"{note}; rev {called_now['rev']} is {called_now['controlled']} in M-Files"
elif note and called_now.get("missing"):
if d.kind == D.REV_MISMATCH and note and called_now.get("refused"):
note = f"{note}; rev {called_now['rev']} is {called_now['controlled']} in M-Files - not attached"
elif d.kind == D.REV_MISMATCH and note and called_now.get("missing"):
note = f"{note}; rev {called_now['rev']} is not in M-Files"
if d.kind == D.ATTACH and fetched is not None:
use_found, use_fetched = found, fetched
if d.kind == D.ATTACH_CALLED and cc is not None and cc.found is not None and cc.fetched is not None:
use_found, use_fetched = cc.found, cc.fetched
if d.kind in (D.ATTACH, D.ATTACH_CALLED) and use_fetched is not None:
label = "Drawing attached from M-Files"
note = "; ".join(x for x in (fetched.name or found.name or "", d.note) if x) or None
detail = {"dwg": dwg, "rev": found.rev, "kind": d.kind, "asset_id": str(fetched.asset_id) if fetched else None}
note = "; ".join(x for x in (use_fetched.name or use_found.name or "", d.note) if x) or None
detail = {
"dwg": dwg,
"rev": use_found.rev,
"kind": d.kind,
"asset_id": str(use_fetched.asset_id) if use_fetched else None,
}
try:
with writes.system_write(conn, pid, action, label=label, note=note, detail=detail) as sw:
if int(sw.packet["version"]) != int(row["version"]):
raise _Stale
if d.kind == D.ATTACH and fetched is not None:
_attach(sw, pid, dwg, found, fetched, d, flags_after, patch, now)
if d.kind in (D.ATTACH, D.ATTACH_CALLED) and use_fetched is not None:
_attach(sw, pid, dwg, use_found, use_fetched, d, flags_after, patch, now)
counts["attached"] += 1
else:
_status(sw, pid, state_after, flags_after, patch)
@@ -581,6 +619,7 @@ def _attach(
"rev": found.rev,
"note": d.note,
"at": now.isoformat(),
**({"controlled": found.controlled} if found.controlled else {}),
}
merged = {**patch, "rev_confirmed": True, "vault": vault}
conn.execute(