Phase 3: VISUAL feed and M-Files drawings in the worker
Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource map, job tables, order-derived columns with provenance; work order key includes the operation sequence). The real worker on APScheduler with settings re-read each fire, pause, run now, advisory locks, job_runs and a heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache, suspect-batch guard, the apply in one transaction: drafts, change orders, retire-moved, work order gone, closed and cancelled observed, projects and units from order links, auto-release) and the fingerprint. The drawing-lookup job (queue, budget, download on version change, sha256 into assets, decision table, staging for rev approval, drawing-origin change orders). Clerical From VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin Feed settings and resource map. Synthetic fixture generator and a fixture-driven worker in CI with a Playwright scenario. Docs updated. Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI stack, 76 frontend tests, doctor at 0004; one feed and one drawing run against VISUAL DEV and the vault from the developer machine (counts in the changelog). Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
// Change order words shared by the packet page and the boards.
|
||||
import type { ChangeOrder, PacketRead } from "../api/types";
|
||||
|
||||
export const feedOrigin = (co: ChangeOrder) => co.origin === "visual" || co.origin === "drawing";
|
||||
|
||||
/** The old coSummary(): "M00543 Rev 1 -> Rev 2 + 2 other changes", or the first changes. */
|
||||
export function coSummary(co: ChangeOrder): string {
|
||||
if (feedOrigin(co) && co.review !== "approved") {
|
||||
const v = co.vis ?? {};
|
||||
const ch = v.changes ?? [];
|
||||
if (v.dwg_change) {
|
||||
const from = v.from_dwg && v.to_dwg && v.from_dwg.replace(/\s/g, "").toUpperCase() !== v.to_dwg.replace(/\s/g, "").toUpperCase() ? `${v.from_dwg} -> ` : "";
|
||||
return `${from}${v.to_dwg ?? ""} Rev ${v.from_rev || "?"} -> Rev ${v.to_rev ?? "?"}${ch.length ? ` + ${ch.length} other change${ch.length > 1 ? "s" : ""}` : ""}`;
|
||||
}
|
||||
return ch.slice(0, 2).join("; ") + (ch.length > 2 ? ` (+${ch.length - 2} more)` : "") || co.reason || "change from VISUAL";
|
||||
}
|
||||
const parts = co.items.filter((i) => !i.added).map((i) => `${i.old_dwg || "sheet"} Rev ${i.old_rev || "?"} -> Rev ${i.new_rev || "?"}`);
|
||||
const added = co.items.filter((i) => i.added).length;
|
||||
if (added) parts.push(`${added} sheet${added > 1 ? "s" : ""} added`);
|
||||
return parts.join(", ") || co.reason || "documents changed";
|
||||
}
|
||||
|
||||
/** The pending feed change order Clerical should review, if any. */
|
||||
export function coToReview(p: PacketRead): ChangeOrder | undefined {
|
||||
return (p.change_orders ?? []).find((c) => c.review === "pending" && feedOrigin(c));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Words for the worker's job runs and the feed status line. Shared by Clerical and Admin.
|
||||
import type { FeedOverview, JobRun } from "../api/types";
|
||||
import { fmtAgo, fmtIn } from "./format";
|
||||
|
||||
/** One count from a run, as a number. Missing or odd values are 0. */
|
||||
export function count(run: Pick<JobRun, "counts"> | null | undefined, key: string): number {
|
||||
const v = run?.counts?.[key];
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
/** "4 new, 2 updated, 1 change order" or "no changes". */
|
||||
export function runSummary(run: JobRun): string {
|
||||
if (run.status === "skipped") {
|
||||
const why = run.counts?.reason;
|
||||
return typeof why === "string" && why ? `skipped (${why})` : "skipped";
|
||||
}
|
||||
if (run.status === "failed") return run.errors[0] ? `failed: ${run.errors[0]}` : "failed";
|
||||
if (run.status === "running") return "running";
|
||||
if (run.status === "abandoned") return "stopped when the worker restarted";
|
||||
const parts: string[] = [];
|
||||
const nw = count(run, "new_drafts");
|
||||
const up = count(run, "drafts_updated");
|
||||
const co = count(run, "cos_opened") + count(run, "cos_reaimed");
|
||||
if (nw) parts.push(`${nw} new`);
|
||||
if (up) parts.push(`${up} updated`);
|
||||
if (co) parts.push(plural(co, "change order"));
|
||||
const att = count(run, "attached");
|
||||
if (att) parts.push(`${att} drawing${att === 1 ? "" : "s"} attached`);
|
||||
const text = parts.length ? parts.join(", ") : "no changes";
|
||||
return run.status === "degraded" ? `${text} (some reads failed)` : text;
|
||||
}
|
||||
|
||||
export const RUN_STATUS_LABEL: Record<string, string> = {
|
||||
running: "Running",
|
||||
ok: "OK",
|
||||
degraded: "Degraded",
|
||||
failed: "Failed",
|
||||
skipped: "Skipped",
|
||||
abandoned: "Abandoned",
|
||||
};
|
||||
|
||||
export function runPillClass(status: string): string {
|
||||
if (status === "ok") return "pill st-ok";
|
||||
if (status === "failed" || status === "abandoned") return "pill st-bad";
|
||||
if (status === "degraded") return "pill st-review";
|
||||
if (status === "running") return "pill st-in_progress";
|
||||
return "pill st-filed";
|
||||
}
|
||||
|
||||
export const TRIGGER_LABEL: Record<string, string> = {
|
||||
schedule: "Schedule",
|
||||
request: "Run now",
|
||||
chain: "After the feed",
|
||||
cli: "Command line",
|
||||
};
|
||||
|
||||
/** Is the feed running right now, by the heartbeat or the last run row. */
|
||||
export function feedRunning(ov: FeedOverview): boolean {
|
||||
return !!ov.status.worker?.running?.includes("visual-feed") || ov.last_run?.status === "running";
|
||||
}
|
||||
|
||||
/** The status line on the Clerical panel. */
|
||||
export function statusLine(ov: FeedOverview, now: Date = new Date()): { text: string; warn: boolean } {
|
||||
const w = ov.status.worker;
|
||||
if (!w || w.stale) return { text: "Worker not running. VISUAL is not being read. Tell IT.", warn: true };
|
||||
if (feedRunning(ov)) return { text: "Reading VISUAL now.", warn: false };
|
||||
const last = ov.last_run;
|
||||
const lastText = last ? `last run ${fmtAgo(last.finished_at ?? last.started_at, now)}: ${runSummary(last)}` : "no runs yet";
|
||||
if (ov.status.paused) return { text: `Paused by the admin · ${lastText}`, warn: true };
|
||||
const next = ov.status.next_run_at ? `next run ${fmtIn(ov.status.next_run_at, now)}` : "next run not scheduled";
|
||||
return { text: `On · ${next} · ${lastText}`, warn: last?.status === "failed" };
|
||||
}
|
||||
|
||||
/** Drawings attached by the drawing runs that followed each feed run. Runs are newest first. */
|
||||
export function drawingsAfter(runs: JobRun[], drawingRuns: JobRun[]): Map<number, number> {
|
||||
const out = new Map<number, number>();
|
||||
runs.forEach((r, i) => {
|
||||
const from = r.started_at;
|
||||
const to = i > 0 ? runs[i - 1]?.started_at : undefined;
|
||||
let n = 0;
|
||||
for (const d of drawingRuns) {
|
||||
if (d.started_at >= from && (to === undefined || d.started_at < to)) n += count(d, "attached");
|
||||
}
|
||||
out.set(r.id, n);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// The Admin feed settings form: fields, ranges and the patch of changed fields.
|
||||
import type { FeedSettings } from "../api/types";
|
||||
|
||||
export const DAYS: ReadonlyArray<[number, string]> = [
|
||||
[0, "Mon"],
|
||||
[1, "Tue"],
|
||||
[2, "Wed"],
|
||||
[3, "Thu"],
|
||||
[4, "Fri"],
|
||||
[5, "Sat"],
|
||||
[6, "Sun"],
|
||||
];
|
||||
|
||||
export type NumKey =
|
||||
| "interval_shop_minutes"
|
||||
| "interval_off_minutes"
|
||||
| "window_back_days"
|
||||
| "window_ahead_days"
|
||||
| "drawing_recheck_hours"
|
||||
| "drawing_budget"
|
||||
| "drawing_interval_minutes"
|
||||
| "auto_release_max";
|
||||
|
||||
/** Each number field: label, lowest, highest. The server checks the same ranges. */
|
||||
export const NUMS: ReadonlyArray<[NumKey, string, number, number]> = [
|
||||
["interval_shop_minutes", "Every (min) in shop hours", 1, 1440],
|
||||
["interval_off_minutes", "Every (min) outside shop hours", 1, 1440],
|
||||
["window_back_days", "Released up to (days back)", 0, 120],
|
||||
["window_ahead_days", "and (days ahead)", 0, 365],
|
||||
["drawing_interval_minutes", "Drawing lookups every (min)", 1, 1440],
|
||||
["drawing_recheck_hours", "Recheck a drawing after (hours)", 1, 168],
|
||||
["drawing_budget", "Drawing lookups per run", 1, 500],
|
||||
["auto_release_max", "Auto-release at most (per run)", 1, 500],
|
||||
];
|
||||
|
||||
export interface Form {
|
||||
nums: Record<NumKey, string>;
|
||||
shop_days: number[];
|
||||
shop_start: string;
|
||||
shop_end: string;
|
||||
golive_date: string;
|
||||
departments: string[];
|
||||
labor_tickets: boolean;
|
||||
auto_release: boolean;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
export function toForm(s: FeedSettings): Form {
|
||||
const nums = {} as Record<NumKey, string>;
|
||||
for (const [k] of NUMS) nums[k] = String(s[k] ?? "");
|
||||
return {
|
||||
nums,
|
||||
shop_days: [...(s.shop_days ?? [])].sort(),
|
||||
shop_start: s.shop_start ?? "",
|
||||
shop_end: s.shop_end ?? "",
|
||||
golive_date: s.golive_date ?? "",
|
||||
departments: [...(s.departments ?? [])].sort(),
|
||||
labor_tickets: !!s.labor_tickets,
|
||||
auto_release: !!s.auto_release,
|
||||
paused: !!s.paused,
|
||||
};
|
||||
}
|
||||
|
||||
const sameList = <T,>(a: readonly T[], b: readonly T[]) => a.length === b.length && a.every((x, i) => x === b[i]);
|
||||
|
||||
/** The changed fields only, or an error text. */
|
||||
export function feedPatch(was: FeedSettings, f: Form): { patch: Partial<FeedSettings> } | { error: string } {
|
||||
const patch: Partial<FeedSettings> = {};
|
||||
for (const [k, label, lo, hi] of NUMS) {
|
||||
const raw = f.nums[k].trim();
|
||||
const n = Number(raw);
|
||||
if (raw === "" || !Number.isInteger(n) || n < lo || n > hi) return { error: `${label}: a whole number from ${lo} to ${hi}.` };
|
||||
if (n !== was[k]) patch[k] = n;
|
||||
}
|
||||
if (!/^\d{2}:\d{2}$/.test(f.shop_start) || !/^\d{2}:\d{2}$/.test(f.shop_end)) return { error: "Shop hours need a start and an end time." };
|
||||
if (f.shop_start >= f.shop_end) return { error: "Shop hours must end after they start." };
|
||||
if (!f.shop_days.length) return { error: "Pick at least one shop day." };
|
||||
const days = [...f.shop_days].sort();
|
||||
if (!sameList(days, [...(was.shop_days ?? [])].sort())) patch.shop_days = days;
|
||||
if (f.shop_start !== was.shop_start) patch.shop_start = f.shop_start;
|
||||
if (f.shop_end !== was.shop_end) patch.shop_end = f.shop_end;
|
||||
const golive = f.golive_date || null;
|
||||
if (golive !== (was.golive_date ?? null)) patch.golive_date = golive;
|
||||
const depts = [...f.departments].sort();
|
||||
if (!sameList(depts, [...(was.departments ?? [])].sort())) patch.departments = depts;
|
||||
if (f.labor_tickets !== !!was.labor_tickets) patch.labor_tickets = f.labor_tickets;
|
||||
if (f.auto_release !== !!was.auto_release) patch.auto_release = f.auto_release;
|
||||
if (f.paused !== !!was.paused) patch.paused = f.paused;
|
||||
return { patch };
|
||||
}
|
||||
|
||||
@@ -42,3 +42,42 @@ export function errorText(err: unknown): string {
|
||||
}
|
||||
|
||||
export const DIGITS = /^\d+$/;
|
||||
|
||||
/** Whole minutes between two times, rounded, never negative. */
|
||||
function minutesBetween(a: number, b: number): number {
|
||||
return Math.max(0, Math.round((b - a) / 60_000));
|
||||
}
|
||||
|
||||
function spanText(mins: number): string {
|
||||
if (mins < 1) return "under a minute";
|
||||
if (mins < 90) return `${mins} min`;
|
||||
const h = Math.round(mins / 60);
|
||||
if (h < 36) return `${h} h`;
|
||||
return `${Math.round(h / 24)} d`;
|
||||
}
|
||||
|
||||
/** "2 min ago", "just now", or a dash. */
|
||||
export function fmtAgo(iso: string | null | undefined, now: Date = new Date()): string {
|
||||
if (!iso) return "-";
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return iso;
|
||||
const m = minutesBetween(t, now.getTime());
|
||||
return m < 1 ? "just now" : `${spanText(m)} ago`;
|
||||
}
|
||||
|
||||
/** "in 3 min", "now", or a dash. */
|
||||
export function fmtIn(iso: string | null | undefined, now: Date = new Date()): string {
|
||||
if (!iso) return "-";
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return iso;
|
||||
const m = minutesBetween(now.getTime(), t);
|
||||
return m < 1 ? "now" : `in ${spanText(m)}`;
|
||||
}
|
||||
|
||||
/** A date-only ISO string as "Oct 16 2026". */
|
||||
export function fmtDay(iso: string | null | undefined): string {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(`${iso.slice(0, 10)}T12:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
@@ -39,9 +39,46 @@ export function projectPillClass(s: ProjectStatus): string {
|
||||
|
||||
/** A flag text that names a hard problem shows in pink, the rest in amber. */
|
||||
export function flagClass(flag: string): string {
|
||||
return /couldn't|not in|mismatch|No approved|Not released/i.test(flag) ? "due over" : "due soon";
|
||||
return /couldn't|not in|mismatch|No approved|Not released|failed|cancelled|not a number/i.test(flag) ? "due over" : "due soon";
|
||||
}
|
||||
|
||||
/** The flag kinds the find bar filters on. The value goes to GET /api/packets?flag=. "1" is any flag. */
|
||||
export const FLAG_FILTERS: ReadonlyArray<[string, string]> = [
|
||||
["", "All packets"],
|
||||
["1", "All flags"],
|
||||
["drawing_needed", "Drawing needed"],
|
||||
["rev_mismatch", "Rev mismatch"],
|
||||
["not_released", "Not released"],
|
||||
["not_found", "Not found"],
|
||||
["lookup_failed", "Lookup failed"],
|
||||
["wo_gone", "Work order gone"],
|
||||
["cancelled", "Cancelled"],
|
||||
["hand", "Hand upload"],
|
||||
];
|
||||
|
||||
/** Short labels for flag kinds on cards and lists. */
|
||||
export const FLAG_KIND_LABEL: Record<string, string> = {
|
||||
drawing_needed: "Drawing needed",
|
||||
rev_mismatch: "Rev mismatch",
|
||||
not_released: "Not released",
|
||||
not_found: "Not found",
|
||||
lookup_failed: "Lookup failed",
|
||||
bad_rev: "Bad rev",
|
||||
no_drawing: "No drawing",
|
||||
wo_gone: "Work order gone",
|
||||
cancelled: "Cancelled",
|
||||
hand: "Made by hand",
|
||||
hand_drawing: "Hand drawing",
|
||||
hand_bom: "Hand BOM",
|
||||
};
|
||||
|
||||
export function flagKindLabel(k: string): string {
|
||||
return FLAG_KIND_LABEL[k] ?? k.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
/** The drawing flags a VISUAL draft carries until M-Files gives a usable drawing. */
|
||||
export const DRAWING_FLAGS = ["drawing_needed", "rev_mismatch", "not_released", "not_found", "lookup_failed", "bad_rev"] as const;
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** The old dueInfo(): "Due Oct 20 '26 · 4 d left", with a class for colour. */
|
||||
|
||||
Reference in New Issue
Block a user