2026-10-16 14:45:38 -06:00
|
|
|
"""The drawing decision table. Pure: no database, no M-Files.
|
|
|
|
|
|
|
|
|
|
`decide(target, found, same_file=...)` says what the drawing job does to one packet for one lookup. Note texts are
|
|
|
|
|
the app's own (from the old revMismatch and draftLine in visual.js, "-" in place of the em-dash), never M-Files'
|
|
|
|
|
note passed through, except inside the lookup_failed text.
|
|
|
|
|
|
|
|
|
|
Rows, in the order they are tried:
|
|
|
|
|
1. lookup failed -> flag lookup_failed, keep what is attached (state lookup_failed only when it was needed).
|
|
|
|
|
2. past draft, a pending feed change order waits for this drawing at the released rev -> stage_for_co.
|
|
|
|
|
Past draft, the pending change order already stages this drawing at the released rev -> unchanged, or
|
|
|
|
|
stage_for_co again when M-Files has a different file for that rev.
|
|
|
|
|
3. no released rev, a draft rev or a drafting assignment -> flag not_released with the drafting status.
|
|
|
|
|
4. no released rev otherwise -> flag not_found.
|
|
|
|
|
5. VISUAL rev not a number (and no approved rev equal to M-Files') -> flag bad_rev, not attached.
|
|
|
|
|
6. revs differ (and no approved rev equal to M-Files') -> flag rev_mismatch. The job stages the M-Files copy
|
2026-10-19 13:36:42 -06:00
|
|
|
for Clerical Supervisor approval on drafts only, and on a draft also copies the rev VISUAL calls when M-Files
|
|
|
|
|
still holds it (Retired, say), so Clerical can choose either.
|
|
|
|
|
Revs differ, but Clerical chose to keep the rev VISUAL calls while M-Files' Effective rev was this one ->
|
|
|
|
|
unchanged (no flag, no change order). A newer Effective rev flags the packet again.
|
2026-10-16 14:45:38 -06:00
|
|
|
7. revs match, draft with a hand drawing -> unchanged (the feed already took the hand drawing off on a rev move).
|
|
|
|
|
8. revs match, draft with this very file attached at this rev -> unchanged.
|
|
|
|
|
9. revs match, draft -> attach ("sheet of drawing <base>" for an H sheet).
|
|
|
|
|
10. revs match, past draft, nothing attached -> unchanged.
|
|
|
|
|
11. revs match, past draft, same file as attached -> unchanged.
|
|
|
|
|
12. revs match, past draft, a pending change order already stages this file, or Clerical rejected a change order
|
|
|
|
|
for this file -> unchanged.
|
|
|
|
|
13. revs match, past draft, attached rev lower -> open_co "M-Files released rev N of <dwg>; this packet has rev M".
|
|
|
|
|
14. revs match, past draft, attached rev equal, new file -> open_co "M-Files re-released rev N of <dwg> (new file)".
|
|
|
|
|
15. revs match, past draft, attached rev higher or not a number -> unchanged.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
from .lookup import Found
|
|
|
|
|
|
|
|
|
|
LOOKUP_FAILED = "lookup_failed"
|
|
|
|
|
NOT_RELEASED = "not_released"
|
|
|
|
|
NOT_FOUND = "not_found"
|
|
|
|
|
BAD_REV = "bad_rev"
|
|
|
|
|
REV_MISMATCH = "rev_mismatch"
|
|
|
|
|
ATTACH = "attach"
|
|
|
|
|
UNCHANGED = "unchanged"
|
|
|
|
|
OPEN_CO = "open_co"
|
|
|
|
|
STAGE_FOR_CO = "stage_for_co"
|
|
|
|
|
|
|
|
|
|
KINDS = (ATTACH, UNCHANGED, REV_MISMATCH, NOT_RELEASED, NOT_FOUND, LOOKUP_FAILED, BAD_REV, OPEN_CO, STAGE_FOR_CO)
|
|
|
|
|
FLAG_KINDS = (LOOKUP_FAILED, NOT_RELEASED, NOT_FOUND, BAD_REV, REV_MISMATCH)
|
|
|
|
|
|
|
|
|
|
# drawing_state enum values (0003). bad_rev has no state of its own: it is a rev mismatch Clerical can approve.
|
|
|
|
|
STATE_OF = {NOT_RELEASED: "not_released", NOT_FOUND: "not_found", BAD_REV: "rev_mismatch", REV_MISMATCH: "rev_mismatch"}
|
|
|
|
|
HELD_STATES = ("attached", "hand")
|
|
|
|
|
|
|
|
|
|
NOT_FOUND_NOTE = "Drawing not in M-Files - check the drawing number in VISUAL"
|
|
|
|
|
_FAILED_PREFIX = "M-Files lookup failed: "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Target:
|
|
|
|
|
dwg: str
|
|
|
|
|
visual_rev: str
|
|
|
|
|
attached_rev: str | None # rev of the live drawing pages; None when no drawing page is attached
|
|
|
|
|
attached_sha: bytes | None
|
|
|
|
|
status: str # packet status
|
|
|
|
|
drawing_state: str
|
|
|
|
|
has_hand_drawing: bool
|
|
|
|
|
rev_ok: str | None # the rev Clerical approved for this VISUAL rev, if any
|
|
|
|
|
# Additions to the contract, all defaulted: the pending feed change order (origin visual or drawing), if any.
|
|
|
|
|
co_waiting: tuple[str, str] | None = None # (dwg, rev) the pending CO waits for
|
|
|
|
|
co_staged: tuple[str, str] | None = None # (dwg, rev) the pending CO already stages
|
|
|
|
|
co_pending: bool = False
|
|
|
|
|
co_staged_sha: bytes | None = None # sha256 of the file the pending CO already stages
|
|
|
|
|
rejected_sha: bytes | None = None # sha256 staged on the last drawing-origin CO Clerical rejected
|
2026-10-19 13:36:42 -06:00
|
|
|
# Clerical chose the rev VISUAL calls (a Retired rev) over M-Files' Effective rev; this is that Effective rev.
|
|
|
|
|
kept_latest: str | None = None
|
2026-10-16 14:45:38 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Decision:
|
|
|
|
|
kind: str
|
|
|
|
|
flag: str | None
|
|
|
|
|
drawing_state: str
|
|
|
|
|
note: str
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
def as_int(v: object) -> int | None:
|
|
|
|
|
"""A rev as a number: "3" -> 3; "A", "" and None -> None."""
|
2026-10-16 14:45:38 -06:00
|
|
|
s = "" if v is None else str(v).strip()
|
|
|
|
|
return int(s) if s.isdigit() else None
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
_int = as_int
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
def draft_line(f: Found) -> str:
|
|
|
|
|
"""The old draftLine: "rev N <where>", or "in drafting, no released PDF yet", or ""."""
|
|
|
|
|
if f.draft_rev is not None and f.draft_where:
|
|
|
|
|
return f"rev {f.draft_rev} {f.draft_where}"
|
|
|
|
|
if f.in_drafting:
|
|
|
|
|
return "in drafting, no released PDF yet"
|
|
|
|
|
if f.draft_rev is not None:
|
|
|
|
|
return f"rev {f.draft_rev}"
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def failed_note(f: Found) -> str:
|
|
|
|
|
detail = (f.note or "").strip()
|
|
|
|
|
if detail.startswith(_FAILED_PREFIX): # MFilesKit's own note already carries the prefix
|
|
|
|
|
detail = detail[len(_FAILED_PREFIX) :]
|
|
|
|
|
return f"{_FAILED_PREFIX}{detail or 'no detail'}"
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
def kept_called(t: Target, f: Found) -> bool:
|
|
|
|
|
"""Clerical kept the rev VISUAL calls, and M-Files' Effective rev is still the one they chose against."""
|
|
|
|
|
return f.ok and f.rev is not None and t.kept_latest is not None and str(t.kept_latest).strip() == str(f.rev)
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
def rev_confirmed(t: Target, f: Found) -> bool:
|
2026-10-19 13:36:42 -06:00
|
|
|
"""The old revConfirmed: M-Files' released rev is the rev VISUAL calls, or Clerical approved that rev, or Clerical
|
|
|
|
|
kept the rev VISUAL calls over that rev."""
|
2026-10-16 14:45:38 -06:00
|
|
|
if not f.ok or f.rev is None:
|
|
|
|
|
return False
|
|
|
|
|
v = _int(t.visual_rev)
|
|
|
|
|
if v is not None and v == f.rev:
|
|
|
|
|
return True
|
2026-10-19 13:36:42 -06:00
|
|
|
if kept_called(t, f):
|
|
|
|
|
return True
|
2026-10-16 14:45:38 -06:00
|
|
|
return t.rev_ok is not None and str(t.rev_ok).strip() == str(f.rev)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decide(t: Target, f: Found, *, same_file: bool, fetched_sha: bytes | None = None) -> Decision:
|
|
|
|
|
"""What to do to one packet.
|
|
|
|
|
|
|
|
|
|
`same_file`: the M-Files file has the sha256 of the attached drawing asset. `fetched_sha`: the sha256 of the
|
|
|
|
|
M-Files file, when it was fetched (row 12 compares it with the pending and the rejected change orders).
|
|
|
|
|
"""
|
|
|
|
|
held = t.drawing_state in HELD_STATES
|
|
|
|
|
draft = t.status == "draft"
|
|
|
|
|
|
|
|
|
|
def flag(kind: str, note: str) -> Decision:
|
|
|
|
|
return Decision(kind, kind, t.drawing_state if held else STATE_OF[kind], note)
|
|
|
|
|
|
|
|
|
|
def keep(kind: str, note: str = "") -> Decision:
|
|
|
|
|
return Decision(kind, None, t.drawing_state, note)
|
|
|
|
|
|
|
|
|
|
# 1
|
|
|
|
|
if not f.ok:
|
|
|
|
|
return Decision(
|
|
|
|
|
LOOKUP_FAILED,
|
|
|
|
|
LOOKUP_FAILED,
|
|
|
|
|
LOOKUP_FAILED if t.drawing_state == "needed" else t.drawing_state,
|
|
|
|
|
failed_note(f),
|
|
|
|
|
)
|
|
|
|
|
# 2
|
|
|
|
|
if not draft and f.rev is not None and t.co_waiting is not None:
|
|
|
|
|
w_dwg, w_rev = t.co_waiting
|
|
|
|
|
if w_dwg.strip() == t.dwg.strip() and str(w_rev).strip() == str(f.rev):
|
|
|
|
|
return keep(STAGE_FOR_CO, f"Drawing for the change order is in M-Files (rev {f.rev})")
|
|
|
|
|
if not draft and f.rev is not None and t.co_staged is not None:
|
|
|
|
|
s_dwg, s_rev = t.co_staged
|
|
|
|
|
if s_dwg.strip() == t.dwg.strip() and str(s_rev).strip() == str(f.rev):
|
|
|
|
|
if fetched_sha is not None and t.co_staged_sha is not None and fetched_sha != t.co_staged_sha:
|
|
|
|
|
return keep(STAGE_FOR_CO, f"New file for rev {f.rev} in M-Files; staged again for the change order")
|
|
|
|
|
return keep(UNCHANGED, f"Rev {f.rev} is staged on the pending change order")
|
|
|
|
|
# 3, 4
|
|
|
|
|
if f.rev is None:
|
|
|
|
|
d = draft_line(f)
|
|
|
|
|
if d:
|
|
|
|
|
on = f" (sheet of {f.base})" if f.base else ""
|
|
|
|
|
return flag(NOT_RELEASED, f"Not released in M-Files yet{on} - drafting status: {d}")
|
|
|
|
|
return flag(NOT_FOUND, NOT_FOUND_NOTE)
|
|
|
|
|
approved = t.rev_ok is not None and str(t.rev_ok).strip() == str(f.rev)
|
|
|
|
|
vrev = (t.visual_rev or "").strip()
|
|
|
|
|
v = _int(vrev)
|
2026-10-19 13:36:42 -06:00
|
|
|
if not approved and kept_called(t, f):
|
|
|
|
|
return keep(UNCHANGED, f"Rev {vrev} kept by Clerical; M-Files has rev {f.rev}")
|
2026-10-16 14:45:38 -06:00
|
|
|
if not approved:
|
|
|
|
|
# 5
|
|
|
|
|
if v is None:
|
|
|
|
|
return flag(BAD_REV, f"VISUAL rev {vrev or '-'} is not a number; M-Files has rev {f.rev} - not attached")
|
|
|
|
|
# 6
|
|
|
|
|
if v != f.rev:
|
|
|
|
|
d = f"; drafting status: {draft_line(f)}" if f.draft_rev is not None and f.draft_rev > f.rev else ""
|
|
|
|
|
return flag(REV_MISMATCH, f"Rev mismatch: VISUAL calls rev {vrev}, M-Files has rev {f.rev}{d}")
|
|
|
|
|
sheet = f"sheet of drawing {f.base}" if f.base else ""
|
|
|
|
|
if draft:
|
|
|
|
|
# 7
|
|
|
|
|
if t.has_hand_drawing:
|
|
|
|
|
return keep(UNCHANGED, "A drawing uploaded by hand stays at the same rev")
|
|
|
|
|
# 8
|
|
|
|
|
if t.drawing_state == "attached" and same_file and t.attached_rev == str(f.rev):
|
|
|
|
|
return keep(UNCHANGED, sheet)
|
|
|
|
|
# 9
|
|
|
|
|
return Decision(ATTACH, None, "attached", sheet)
|
|
|
|
|
# 10, 11
|
|
|
|
|
if t.attached_rev is None or same_file:
|
|
|
|
|
return keep(UNCHANGED, sheet)
|
|
|
|
|
# 12
|
|
|
|
|
if fetched_sha is not None and fetched_sha in (t.co_staged_sha, t.rejected_sha):
|
|
|
|
|
return keep(UNCHANGED, sheet)
|
|
|
|
|
att = _int(t.attached_rev)
|
|
|
|
|
if att is None or att > f.rev:
|
|
|
|
|
return keep(UNCHANGED, sheet) # 15
|
|
|
|
|
if att < f.rev:
|
|
|
|
|
reason = f"M-Files released rev {f.rev} of {t.dwg}; this packet has rev {att}"
|
|
|
|
|
else:
|
|
|
|
|
reason = f"M-Files re-released rev {f.rev} of {t.dwg} (new file)"
|
|
|
|
|
return keep(OPEN_CO, reason)
|