Phase 3: VISUAL feed and M-Files drawings in the worker
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s

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:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
@@ -0,0 +1,422 @@
-- 0004_feed: the VISUAL and drawing caches, the go-live ignored list, the resource map, the worker's
-- job tables and the order-derived columns on projects and units. Runs as ps_migrate; 0001's default
-- privileges give ps_app SELECT, INSERT, UPDATE, DELETE on every table here. Every work order ID part
-- (base, lot, split, sub) and every part ID is text: leading zeros are kept and lot 7A occurs.
-- ---------------------------------------------------------------- VISUAL cache
-- One row per feed read. suspect batches skip the "gone" marking.
CREATE TABLE visual_batches (
id bigserial PRIMARY KEY,
run_id bigint,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
server text,
database text,
site text,
since date,
until date,
window_rows int,
tracked_keys int,
missing_keys int,
suspect bool NOT NULL DEFAULT false,
counts jsonb NOT NULL DEFAULT '{}'
);
-- One row per TYPE W operation as VISUAL last showed it. st is the status code (F R U C X) or ''.
CREATE TABLE visual_rows (
id bigserial PRIMARY KEY,
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int NOT NULL,
res text NOT NULL DEFAULT '',
dwg text NOT NULL DEFAULT '',
rev text NOT NULL DEFAULT '',
ps text NOT NULL DEFAULT '',
descr text NOT NULL DEFAULT '',
qty numeric,
tgt numeric,
rel date,
want date,
st text NOT NULL DEFAULT '',
part text NOT NULL DEFAULT '',
barcode text,
row_hash bytea NOT NULL,
first_batch bigint NOT NULL REFERENCES visual_batches (id),
last_batch bigint NOT NULL REFERENCES visual_batches (id),
gone_batch bigint REFERENCES visual_batches (id),
UNIQUE (wo_base, lot, split, sub, seq)
);
CREATE INDEX visual_rows_wo ON visual_rows (wo_base, lot);
CREATE INDEX visual_rows_dwg ON visual_rows (dwg);
CREATE INDEX visual_rows_rel_live ON visual_rows (rel) WHERE gone_batch IS NULL;
-- Material requirement lines. part is NULL on a line with no part (a group header).
CREATE TABLE visual_materials (
id bigserial PRIMARY KEY,
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int NOT NULL,
piece int NOT NULL,
part text,
qty numeric,
issued numeric,
whs text NOT NULL DEFAULT '',
loc text NOT NULL DEFAULT '',
st text NOT NULL DEFAULT '',
istat int,
dwg text NOT NULL DEFAULT '',
descr text NOT NULL DEFAULT '',
comm text NOT NULL DEFAULT '',
cons text NOT NULL DEFAULT '',
mat_hash bytea NOT NULL,
first_batch bigint NOT NULL REFERENCES visual_batches (id),
last_batch bigint NOT NULL REFERENCES visual_batches (id),
gone_batch bigint REFERENCES visual_batches (id),
UNIQUE (wo_base, lot, split, sub, seq, piece)
);
CREATE INDEX visual_materials_wo ON visual_materials (wo_base, lot);
CREATE TABLE visual_orders (
cust_order_id text PRIMARY KEY,
status text,
customer_id text,
customer_name text,
customer_po_ref text,
order_date date,
desired_ship_date date,
promise_date date,
last_batch bigint
);
-- kind base: key is a work order base. kind project: key is a project number.
CREATE TABLE visual_order_links (
id bigserial PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('base', 'project')),
key text NOT NULL,
cust_order_id text NOT NULL REFERENCES visual_orders (cust_order_id),
rank int NOT NULL,
lines int[] NOT NULL DEFAULT '{}',
top_jobs text[] NOT NULL DEFAULT '{}',
last_batch bigint,
UNIQUE (kind, key, cust_order_id)
);
CREATE INDEX visual_order_links_key ON visual_order_links (kind, key);
-- Hours and ticket counts per operation. No cost columns, ever.
CREATE TABLE visual_labor (
id bigserial PRIMARY KEY,
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int NOT NULL,
hours numeric NOT NULL DEFAULT 0,
tickets int NOT NULL DEFAULT 0,
last_ticket date,
observed_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (wo_base, lot, split, sub, seq)
);
-- What VISUAL changed, per batch, for the Admin view. Pruned at 90 days.
CREATE TABLE visual_row_changes (
id bigserial PRIMARY KEY,
batch_id bigint NOT NULL REFERENCES visual_batches (id),
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int,
kind text NOT NULL CHECK (kind IN ('added', 'changed', 'gone', 'returned')),
before jsonb,
after jsonb,
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX visual_row_changes_batch ON visual_row_changes (batch_id);
CREATE INDEX visual_row_changes_at_brin ON visual_row_changes USING brin (at);
CREATE TABLE visual_material_changes (
id bigserial PRIMARY KEY,
batch_id bigint NOT NULL REFERENCES visual_batches (id),
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int,
piece int,
kind text NOT NULL CHECK (kind IN ('added', 'changed', 'gone', 'returned')),
before jsonb,
after jsonb,
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX visual_material_changes_batch ON visual_material_changes (batch_id);
CREATE INDEX visual_material_changes_at_brin ON visual_material_changes USING brin (at);
-- Work orders released before the go-live date. A row pulled in by hand becomes tracked.
CREATE TABLE visual_ignored_wos (
id bigserial PRIMARY KEY,
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
rel date,
project text,
unit_number text,
res text,
dwg text,
first_seen_batch bigint,
last_seen_at timestamptz NOT NULL DEFAULT now(),
pulled_in_at timestamptz,
pulled_in_by uuid REFERENCES users (id),
pulled_in_reason text,
UNIQUE (wo_base, lot, split, sub)
);
-- VISUAL resource to shop department, or fold (an operation folded into the packet) or ignore.
CREATE TABLE visual_resource_map (
resource text PRIMARY KEY,
target text NOT NULL CHECK (target = 'fold' OR target = 'ignore' OR target ~ '^[0-9]{2}$'),
confirmed bool NOT NULL DEFAULT false,
updated_at timestamptz,
updated_by uuid
);
-- Seeded from the old build's sync/visual_map.json. "Electical" is spelled that way in VISUAL.
INSERT INTO visual_resource_map (resource, target) VALUES
('LAYOUT', '01'),
('LAYOUT-BT', '01'),
('LAYOUT-SM BAND', '01'),
('LAYOUT-BEVELER', '01'),
('LAYOUT-ATT', '01'),
('LAYOUT-ROLL', '01'),
('LAYOUT-PRESS', '01'),
('LAYOUT-THREADER', '01'),
('LAYOUT-HAND CUT', '01'),
('LAYOUT-WEST', '02'),
('LT-SS', '02'),
('WELD-PART', '03'),
('NOVARC_1', '03'),
('WELD-PART-WEST', '04'),
('PART-WELD-WEST', '04'),
('WP-SS', '05'),
('WELD-CODE', '06'),
('WELD-CODE-BT', '07'),
('SUBASSEMBLY', '08'),
('WEST-SA', '08'),
('HYDROTEST', '09'),
('WELD-ASY', '10'),
('FINAL ASY-NORTH', '11'),
('PREP-NORTH', '11'),
('FINAL ASSEMBLY', '11'),
('FINAL ASY-SOUTH', '12'),
('PREP-SOUTH', '12'),
('WEST-FA', '12'),
('PAINT', '13'),
('SANDBLAST', '13'),
('FINAL-ASY-ELEC', '14'),
('Electical', '14'),
('MFG', 'fold'),
('INSPECTION', 'fold'),
('REWORK', 'fold'),
('REWORK-CUSTOMER', 'fold'),
('OUTSIDE SERVICE', 'fold'),
('PTU', 'fold'),
('FORKLIFT', 'ignore'),
('STEAM', 'ignore'),
('WIPLOAD', 'ignore'),
('FOREMAN', 'ignore'),
('ENGINEERING', 'ignore'),
('TRAINING', 'ignore'),
('F.S. TECH.', 'ignore'),
('F.S. TECH. ASST', 'ignore'),
('F.S. STEAM', 'ignore'),
('F.S. WELDER', 'ignore'),
('F.S. SANDBLAST', 'ignore'),
('F.S. PAINT', 'ignore'),
('F.S. SAFETY', 'ignore'),
('REP. TECH. A', 'ignore'),
('REP. TECH. B', 'ignore'),
('REP. WELDER A', 'ignore'),
('REP. WELDER B', 'ignore');
-- ---------------------------------------------------------------- drawing cache
-- One row per drawing number as VISUAL writes it (trimmed). asset_id is the last fetched copy.
CREATE TABLE drawing_lookups (
dwg text PRIMARY KEY,
checked_at timestamptz,
ok bool NOT NULL DEFAULT true,
rev int,
name text,
obj_id int,
obj_version int,
file_id int,
file_version int,
file_size bigint,
base text,
draft_rev int,
draft_where text,
in_drafting bool NOT NULL DEFAULT false,
note text,
fail_count int NOT NULL DEFAULT 0,
failing_since timestamptz,
asset_id uuid REFERENCES assets (id),
sha256 bytea,
visual_rev_seen text,
next_check_at timestamptz
);
CREATE INDEX drawing_lookups_next_check ON drawing_lookups (next_check_at) WHERE next_check_at IS NOT NULL;
-- ---------------------------------------------------------------- worker jobs
-- "Run now": the app inserts a row and NOTIFYs ps_job_request; one run claims every pending row.
CREATE TABLE job_requests (
id bigserial PRIMARY KEY,
job text NOT NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
requested_by uuid REFERENCES users (id),
note text,
claimed_run_id bigint,
claimed_at timestamptz
);
CREATE INDEX job_requests_pending ON job_requests (job) WHERE claimed_at IS NULL;
CREATE TABLE job_runs (
id bigserial PRIMARY KEY,
job text NOT NULL,
trigger text NOT NULL CHECK (trigger IN ('schedule', 'request', 'chain', 'cli')),
requested_by uuid[] NOT NULL DEFAULT '{}',
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
status text NOT NULL DEFAULT 'running'
CHECK (status IN ('running', 'ok', 'degraded', 'failed', 'skipped', 'abandoned')),
counts jsonb NOT NULL DEFAULT '{}',
errors jsonb NOT NULL DEFAULT '[]',
build_version text,
visual_target text,
host text
);
CREATE INDEX job_runs_job ON job_runs (job, id DESC);
-- The next run per job as the worker last scheduled it, for the Admin and Clerical screens.
CREATE TABLE job_schedule (
job text PRIMARY KEY,
next_run_at timestamptz,
interval_seconds int,
paused bool NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO job_schedule (job) VALUES ('visual-feed'), ('drawing-lookup'), ('housekeeping');
-- One row, written every 30 seconds by the running worker.
CREATE TABLE worker_heartbeat (
id int PRIMARY KEY CHECK (id = 1),
at timestamptz NOT NULL,
build text,
host text,
pid int,
running text[] NOT NULL DEFAULT '{}'
);
-- ---------------------------------------------------------------- column additions
ALTER TABLE packets
ADD COLUMN visual_cancelled_at timestamptz,
ADD COLUMN wo_gone_at timestamptz;
-- The leg's own drawing, rev and planner instructions as VISUAL carries them.
ALTER TABLE packet_work_orders
ADD COLUMN partspecs text,
ADD COLUMN rev text,
ADD COLUMN dwg text;
-- One row per leg: two operations of one work order may sit on one packet. Hand rows keep seq NULL,
-- and NULLS NOT DISTINCT still allows only one hand row per key.
DO $$
DECLARE
c text;
BEGIN
SELECT con.conname INTO c
FROM pg_constraint con
WHERE con.conrelid = 'packet_work_orders'::regclass
AND con.contype = 'u'
AND (
SELECT array_agg(a.attname::text ORDER BY a.attname)
FROM unnest(con.conkey) k
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
) = ARRAY['lot', 'packet_id', 'split', 'sub', 'wo_base'];
IF c IS NULL THEN
RAISE EXCEPTION 'packet_work_orders unique key (packet_id, wo_base, lot, split, sub) not found';
END IF;
EXECUTE format('ALTER TABLE packet_work_orders DROP CONSTRAINT %I', c);
END;
$$;
ALTER TABLE packet_work_orders
ADD CONSTRAINT packet_work_orders_leg_key UNIQUE NULLS NOT DISTINCT (packet_id, wo_base, lot, split, sub, seq);
-- One pending feed change order per packet, whether VISUAL or M-Files opened it.
DROP INDEX change_orders_visual_pending;
CREATE UNIQUE INDEX change_orders_feed_pending ON change_orders (packet_id)
WHERE origin IN ('visual', 'drawing') AND review = 'pending';
-- order_ref is the order the feed chose; order_pin is one Clerical pinned (with a reason).
ALTER TABLE projects
ADD COLUMN order_date_visual date,
ADD COLUMN ship_date_visual date,
ADD COLUMN order_ref jsonb,
ADD COLUMN order_pin text,
ADD COLUMN order_pin_by uuid REFERENCES users (id),
ADD COLUMN order_pin_at timestamptz,
ADD COLUMN order_pin_reason text;
ALTER TABLE units
ADD COLUMN customer text,
ADD COLUMN po text,
ADD COLUMN customer_visual text,
ADD COLUMN po_visual text,
ADD COLUMN order_date_visual date,
ADD COLUMN ship_date_visual date,
ADD COLUMN order_ref jsonb,
ADD COLUMN order_pin text,
ADD COLUMN order_pin_by uuid REFERENCES users (id),
ADD COLUMN order_pin_at timestamptz,
ADD COLUMN order_pin_reason text;
-- v_units expanded u.* when it was made, so it is rebuilt to carry the new unit columns.
DROP VIEW v_units;
CREATE VIEW v_units AS
SELECT u.*,
p.number || u.lot AS number,
p.number AS project_number,
p.name AS project_name,
p.status AS project_status,
coalesce(u.due, p.due) AS due_effective
FROM units u
JOIN projects p ON p.id = u.project_id;
-- Explicit, after the default privileges: the view is read-only to the app.
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON v_units FROM ps_app;
GRANT SELECT ON v_units TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
-- The feed COPYs each batch into temp tables before the MERGE. ps_migrate owns the database (initdb and
-- bootstrap), so it may grant TEMP; stacks made before this migration get the grant here.
DO $$
BEGIN
EXECUTE format('GRANT TEMPORARY ON DATABASE %I TO ps_app', current_database());
END;
$$;