The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.
Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
32 KiB
Packet Station schema
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the notification_events placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 9 adds the notification rules and delivery. The DDL is in server/packetstation/migrations/; this file explains it. See PLAN.md, "Schema".
Conventions
Identity bigints, except users, keyed on the AD objectGUID. Postgres enums for workflow states. Every timestamp is timestamptz in UTC. Soft delete uses removed_at, removed_by and removed_reason. Append-only tables give the app role insert and select only, plus a trigger that refuses updates and deletes.
Database roles
A superuser for bootstrap, the migrate role ps_migrate (owns the database and the schema, runs migrations) and the app role ps_app. The worker shares ps_app and sets application_name to ps-worker, so the two are told apart in pg_stat_activity; a separate ps_worker role was not needed (decided in phase 3). ps_app holds CONNECT and TEMPORARY on the database (the feed COPYs each batch into temp tables) and no CREATE on the schema.
Identity (phase 1, migration 0002)
Enums: role_kind (admin, clerical, clerical_supervisor, warehouse, warehouse_supervisor, qc, qc_supervisor, pm, shop_worker, shop_lead, shop_supervisor), session_kind (password, pin), sig_factor (password, pin), dept_kind (support, shop).
users:id uuidis the ADobjectGUID, stable across renames. Cachessam_account(unique, case-insensitive),upn,display_name,emp_no(text; indexed but not unique, because a duplicate must be storable and flagged),email,welder_stamp,active,directory_flagsjsonb (disabled, missing or duplicate employee ID),ad_synced_at,last_signin_at. A user whose AD account is disabled or gone keeps the row, marked inactive.user_roles: (user_id,role,dept_code) rebuilt at every sign-in, at the 15-minute role refresh and by the AD refresh. Office roles carry their department code, admin carries null, shop roles carry the two-digit code. Only the highest shop level per department is stored.sessions:token_hash(HMAC-SHA256 of the cookie token with the session key),kind,device_idfor PIN sessions,expires_at(absolute),last_seen_at(idle),locked_since,revoked_atandrevoked_reason,ip,user_agent,roles_snapshotjsonb ({"roles": [...], "groups": [...]}),roles_checked_at,stepup_failures,hidden_since(the last hidden-tab report, cleared by the next request).devices: registered shop devices.name,token_hashof the device cookie,registered_by,last_seen_at,revoked_at,revoked_by.user_pins: one row per user with a PIN. Argon2idhash,failed_count(across sign-in, unlock and signature),disabled_at,set_at. Never readable by Admin in clear; Admin may delete the row.signin_attempts: append-only.account(lower(login),emp:<emp_no>oruser:<uuid>),ip,kind(password, pin, unlock, signature, pin_set),ok,reason,user_id,session_id. Both rate limits count the failed rows here.signatures: append-only. Who (user_id,accountanddisplay_namesnapshots), thesession_idandsession_kind, thefactor,action,meaning,packet_id,subject_type,subject_idand acontent_hash(sha256 of the canonical JSON of what was signed). Later phases reference it from every sign-off row.
Append-only tables use the ps_append_only trigger from 0001 and revoke UPDATE, DELETE and TRUNCATE from ps_app.
Organisation (phase 1 seed, phase 2 completes)
departments:code(text PK,CL,WH,QC,PM,01to14),slug(the AD group short name, unique),name,kind,sort,active,supervisor_group(false only for PM). A trigger refuses changes tocodeandslug. Seeded with the 18 departments from the brief and the olddepts.js. Migration 0003 addsweld(bool, default false), true for 03, 04, 05, 06, 07 and 10. A weld department gets the welding checklist, and submit there needs at least one weld and a filler.stations: (dept_code,name,sort,active), seeded with the old station lists (Stall 1 to 6, Station 1 to 4, Hydro Bay 1 and 2, Blast Booth, Paint Booth 1 and 2).user_station_limits: (user_id,station_id) primary key. A worker with rows here may only work on packets at those stations. No rows means any station in the department. The rows go when the user row goes.
The 50 AD group names are derived from departments plus PS-Admins: PS-<slug> and PS-<slug>-Supervisor for support departments, PS-<code>-<slug>-<Worker|Lead|Supervisor> for shop departments. packetstation ad-groups --list prints them.
Enums (phase 2, migration 0003)
Declaration order matters for packet_status: it is the workflow order, so status >= 'qc' works in SQL.
| Enum | Values in order |
|---|---|
packet_status |
draft, warehouse, ready, assigned, in_progress, review, qc, complete, filed |
wh_stage |
incoming, pulling, hold, pulled |
co_review |
pending, approved, rejected |
co_origin |
visual, drawing, manual |
source_kind |
visual, hand |
project_status |
open, hold, closed |
route_kind |
databook, shipping |
drawing_state |
none, needed, attached, rev_mismatch, not_released, not_found, lookup_failed, hand |
itp_status |
open, signed, reopened |
Smaller closed sets are text with a CHECK: projects.source and every *_src (visual, hand), project_aliases.kind (renumber, unit_renumber, import), packet_pages.type (drawing, info, overlay), bom_lines.src (visual, hand), qc_inspections.result (accepted, rejected), itp_lines.result (approve, reject, na). Null means unset in each.
Projects and units (phase 2)
projects:number(text, unique among live rows onupper(number)),name,customer,po,due,status(project_status),notes,source(hand or visual),needs_details(the feed made it and Clerical has not filled it in),versionfor field CAS. Order-derived values carry provenance:customer_src,po_src,order_date_srcandship_date_srcsay visual or hand, andcustomer_visualandpo_visualkeep what VISUAL said. The feed never overwrites a hand value. Soft delete.units:project_id,lot(text),name,due,status,notes, the same*_srccompanions,order_date,ship_date,version. A CHECK keepslotto 1 to 3 upper-case letters or digits, and a lot of digits only is exactly 3 long (001,7A). Unique among live rows on (project_id,lot). Soft delete.project_aliases:alias(text PK, upper-cased by CHECK),project_id,unit_id(null for a project number),kind(renumber, unit_renumber, import),created_at. Every current and old number of every project and unit is here, so a lookup by any number resolves, and a renumber cannot reuse a number that was ever taken.project_renumbers: append-only.at,by,project_id,unit_id,old_number,new_number,reason.- View
v_units: everyunitscolumn plusnumber(project.number || lot, soL999and001makeL999001),project_number,project_name,project_statusanddue_effective(the unit's due, else the project's).
Packets (phase 2)
Header
packets is the header. The columns that matter:
- Placement:
unit_id,dept_code,op,title,customer,drawing,rev,wps. - Workflow:
status(packet_status),status_at,station_id,welder_idandwelder_stamp(a snapshot taken at assign),for_sup_id,wh_skipped(true when Clerical sent the draft straight to a department or to Q.C.),wh_sent_at,routed_to(route_kind, set at Q.C. accept),return_note(the reason shown after a send-back),vclosed_atandvclosed_by,databook_at. - Preconditions:
heat_mapis nullable on purpose. Null means not answered yet, and the warehouse step needs an answer. - Source:
source_kind(visual or hand),sourcejsonb,visual_key(the feed's match key),visual_rel,visual_want,drawing_state,flagstext[],visual_fp_applied(the fingerprint the packet matches),visual_closed_at,visualjsonb (system-written sub-keys). - Counters:
next_weld_no,next_heat_no,next_page_noandnext_co_no, all starting at 1. See "Versioning". version,created_at,created_by,updated_at, soft delete.
Indexes: packets_visual_key is unique on visual_key where the key is set and the row is live. A removed packet keeps its key, so two removed rows may share one, but only one live row may hold it. Also (status) for live rows, (unit_id) and (dept_code, status) for the boards.
The trigger ps_packet_touch sets updated_at on every UPDATE. It does not touch version.
Children
packet_work_orders:wo_base,lot,split,sub(all text; the last three default to''so the key has no nulls),seq,qty,target_hrs,op,description,visual_status,missing_runsandgone_at(the feed's "work order gone" check),sort,row_version. Phase 3 addspartspecs,revanddwg. Unique on (packet_id,wo_base,lot,split,sub,seq) with nulls not distinct, since migration 0004 (see "VISUAL and drawing caches").packet_pages:n(the packet page number, fromnext_page_no),type,asset_id,pn(page within the asset),dwg,rev,co_id(the change order that added it),void_co_id,void_at,void_by,void_new_n(a replaced page is voided, never deleted),row_version. Unique on (packet_id,n).bom_lines:item,qty,part_id(text),description,dwg_ref,cl_item,length,material,heat,na,issued,loc,pulled,pulled_at,pulled_by,mtr_asset_id,src,vkey,whs,comm,cons,sub,grp,sort,row_version. Unique on (packet_id,item).bom_shorts:bom_line_id(cascades with the line),qty,qty_open,reason,ref,eta,note,opened_at,opened_by,closed_at,row_version. At most one open short per line (unique onbom_line_idwhereclosed_atis null).short_receipts: append-only.short_id,packet_id,qty,heat,note,at,by.
What the warehouse columns mean in practice (phase 4):
bom_lines.pulled: the line is off the shelf. The first tick setspulled_atandpulled_by, and setsissuedtoqtywhen it was empty. Unticking clears all three ofpulled,pulled_atandpulled_by. The first tick while the warehouse stage is incoming or on hold also starts pulling (apacket_whrow withautotrue).bom_lines.issued: how many pieces went out. Less thanqtymeans the rest should be on a short.bom_lines.loc: where the parts were taken from, as the warehouse types it.bom_lines.heat: the heat number of the pieces issued, checked against the heat pattern. A receipt adds its heat to the line, so a line with parts from two heats readsH1 / H2(heats joined with " / ", no repeats).bom_lines.na: the part has no traceability. An N/A line needs no heat, takes no heat, and does not count toward the release gate.bom_lines.mtr_asset_id: the mill test report for the line, anassetsrow that must be a PDF, PNG or JPEG. Null clears it.- A line "needs a heat" when it is not N/A, not a sub-assembly (
subisY), is pulled, hasissuedabove 0 and has no heat. The release gate counts these lines (see POLICY.md). bom_shorts: one open short per line at most. Opening a short sets the line'sissuedtoqtyminus the short quantity and ticks it pulled.qtyis what was short in total,qty_openwhat is still missing.reasonis one of the closed short reasons,refthe PO or requisition,etathe expected date. Editing an open short keeps what was already received inqty. "Not short after all" closes it (closed_at) and setsissuedto the line'sqty. While a short is open, the line's heat, issued and location change only through the short.short_receipts: one row per delivery, never changed. Each receipt lowersqty_open, adds its pieces to the line'sissuedand its heat to the line'sheat. The receipt that bringsqty_opento 0 closes the short.packet_wh:reasonis one of the closed hold reasons when the stage ishold, andnoteis up to 200 characters.packet_stages: one row per visit to a shop department.dept_code,station_id,welder_id,welder_stamp,checklistjsonb (key to{done, at, by, stamp}, written per key withjsonb_set),filler,filler_lot,preheat,notes, the clock (clock_elapsed_ms,clock_running_since,clock_started_at; only the server writes them, see "The clock"),qc_snapshot,opened_at,closed_at,row_version. At most one open stage per packet.welds:weld_no(fromnext_weld_no),page,fxandfy(fractions of the page),bfxandbfy(0006),wo_base,piece,itemsint[],type,size,process,stamp,welder_id,dept_code,stage_id,carried_from,at,by,row_version, soft delete. Unique on (packet_id,weld_no) among live rows.heat_tags:tag_no(fromnext_heat_no),page,item,fx,fy,bfxandbfy(0006),carried_from,at,by,row_version, soft delete. Unique on (packet_id,tag_no) among live rows.heat_table_placements:page,x,y,s,carried_from(0006),by,at. Unique on (packet_id,page), written as an upsert.packet_wh: the warehouse stage history.stage(wh_stage),by,at,reason,note,auto,closed_at. The current row hasclosed_atnull and is unique per packet. A stage change closes it and inserts the next.packet_transitions: append-only. Every status change:at,by,action,from_status,to_status,from_dept,to_dept,reason,signature_id,it_reason. Replaces the oldhistandsentBacklists.qc_inspections:stage_id,sent_at,sent_by,book,spool,location,sent_note,checksjsonb,weld_examjsonb (weld_no to{result, note}),nde_method,nde_result,nde_report,hardness,nace,inspector,stamp,notes,result,result_at,result_by,result_note,signature_id,row_version,closed_at. At most one open inspection per packet.qc_history: append-only.at,result,inspector,note,dept_code,snapshotjsonb.packet_seals: the HMAC-SHA256hashwritten at filing,key_id,sealed_at,sealed_by,signature_id, andbroken_at,broken_by,broken_reasonwhen IT reopens. At most one unbroken seal per packet.packet_log: append-only. The user-visible history:at,by,by_nameandrole(snapshots),action,note,it_reason,signature_id,transition_id. Indexed on (packet_id,id).packet_visual_state: one row per packet, written by the worker.legsjsonb,ticket_hours,order_ref,observed_at.
Every who column references users (id). Every signature_id references signatures (id).
Shop (phase 5, migration 0006)
Column additions
welds:bfxandbfy(double precision, null). The label (bubble) position as fractions of the page.fxandfystay where the arrow points.heat_tags:bfxandbfy, the same.heat_table_placements:carried_from(int, null). The packet page number the table was copied from on a change order page replacement.
A null bfx means the label has not been moved. The client then draws it at the old default offset: bfx = clamp01(fx + (fx > 0.85 ? -0.05 : 0.05)) and bfy = clamp01(fy + (fy < 0.12 ? 0.07 : -0.07)), where clamp01 keeps a value between 0.01 and 0.99.
The API checks the values the columns have no CHECK for:
| Value | Rule | Refusal |
|---|---|---|
fx, fy, bfx, bfy |
0 to 1, or null for bfx and bfy |
422 |
welds.type |
Fillet, Groove, Fill, Seal or Tack | 422 invalid_weld |
welds.process |
GMAW, FCAW, SMAW, GTAW or SAW | 422 invalid_weld |
welds.size |
5/16" on insert when missing |
|
welds.piece |
1 or more | 422 |
welds.wo_base |
one of the packet's work order bases | 422 invalid_wo |
heat_table_placements.x, y |
0 to 0.97 | 422 invalid_heat_table |
heat_table_placements.s |
0.6 to 2.1, in steps of 0.15 from the screen | 422 invalid_heat_table |
page of a heat table placement |
a live or voided drawing page of the packet | 422 invalid_page |
page of a new or moved weld or heat tag |
not a voided page | 422 void_page |
Checklist templates
The settings row checklist_templates holds the shop checklists. Migration 0006 inserts the defaults with ON CONFLICT DO NOTHING, so a row already there is kept. The value:
{
"weld": [{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} matches the packet"}, ...],
"plain": [{"group": "Before starting", "key": "dwg", "text": "..."}, ...]
}
weld is the list for a weld department (departments.weld true), plain for every other shop department. Each entry has a group, a key and a text. The text may hold the placeholders {drawing}, {rev} and {wps}, filled from the packet.
| List | Group | Keys |
|---|---|---|
| weld | Before welding | dwg, heat, wps, fit, pre |
| weld | After welding | vt, stamp, clean, qty |
| plain | Before starting | dwg, prev, mat |
| plain | When done | work, insp, qty |
When the row is missing or malformed, the defaults in domain/checklists.py apply and a warning is logged. The read model and the checklist write both use the loaded templates. No screen edits the row in phase 5. Phase 9 (Admin) adds that.
packet_stages.checklist maps each key to {done, at, by, stamp}. by is the user id. stamp is the user's welder_stamp, else the packet's. A key not in the department's list is 422.
Carried marks
Welds, heat tags and heat table placements belong to the packet, not to the stage. Routing a packet to another department copies nothing. The read model computes a carried flag instead:
- A weld is carried when
stage_idis set and is not the open stage's id. It was logged in an earlier department's stage. - A heat tag is carried when its
atis before the open stage'sopened_at. The warehouse or an earlier department placed it.
The carried_from columns on welds and heat_tags (from 0003) are not written by routing.
There is one copy, on a change order page replacement. When a feed change order is approved and a drawing page is voided, each heat table placement on the voided page is copied to its new page with carried_from set to the old page number. The log says Heat table copied to new sheet with the note from the VOID copy, check its position on the new revision. Welds and heat tags stay on the void page.
The clock
packet_stages has three clock columns (from 0003):
clock_elapsed_ms(bigint, default 0): the stored total.clock_running_since(timestamptz): when the clock last started. Null while it is stopped.clock_started_at(timestamptz): the first start of the stage.
Only the server writes them, with the database now(). No request field sets them, and a value from the client is never used.
| Event | Write |
|---|---|
START, or clock start |
clock_running_since = now() when it is not running; clock_started_at = coalesce(clock_started_at, now()) |
clock pause |
clock_elapsed_ms += now() - clock_running_since; clock_running_since = null |
clock resume |
clock_running_since = now() |
| PULL_BACK, UNASSIGN, SUBMIT | fold: clock_elapsed_ms += now() - clock_running_since when running; clock_running_since = null |
A clock op in the wrong state is 409 clock_state.
The read model gives server_now (the database now()) and stage.clock: {elapsed_ms, stored_ms, running, running_since, started_at}. stored_ms is the column. elapsed_ms is live: the stored total plus now() - clock_running_since while running. Each closed stage has the same object, with running false. The client adds the time since the fetch to elapsed_ms for the ticking display only.
Change orders (phases 2 and 3)
change_orders:co_no(fromnext_co_no; unique per packet),origin(co_origin),review(co_review),minor,hold,held_by,held_at,ecn,reason,issued_*,approved_*andrejected_*(each with a*_signature_id),vis,waitingandstagedjsonb (feed-shaped),target_fp,row_version. At most one pending VISUAL change order per packet (unique onpacket_idwhere origin is visual and review is pending), so the feed re-aims it instead of stacking new ones.co_items: the page changes.old_page,old_dwg,old_rev,new_page,new_dwg,new_rev,added.co_required_depts: (co_id,dept_code) primary key. The departments that must verify.co_verifications: append-only, keyed on (co_id,dept_code).byandsignature_idare required, plusat,note,it_reason. A primary key violation means the department already verified. A verification cannot be withdrawn.- View
v_open_cosports the oldopenCOs():packet_id,co_id,co_no,review,hold,pending_depts(text[], the required departments without a verification, sorted by code). A row appears when review is approved andpending_deptsis not empty, or when review is pending andholdis true. A pending change order without hold holds nothing. A rejected one never appears. The policy's CO hold and the boards' open-CO count read this view.
Documents (phases 2, 4 and 6)
assets:iduuid,sha256(indexed, for dedupe),size,mime,original_name,storage_key(the path under ASSETS_DIR, unique),source(uploadby default),source_refjsonb,pages,uploaded_by,uploaded_at,packet_id(the packet it was uploaded for, without a foreign key;packet_pagesandpacket_docsare the real links).
Asset kinds (phase 4). An upload names its kind in the purpose field, and source_ref keeps {"purpose": kind}. No purpose means document. The type is read from the content, never from the name or the browser.
| Kind | Types | Limit (setting) |
|---|---|---|
| drawing | 25 MB (upload_max_bytes) |
|
| mtr | PDF, PNG, JPEG | 25 MB (upload_max_bytes) |
| document | PDF, PNG, JPEG | 25 MB (upload_max_bytes) |
| bom | XLSX, CSV | 5 MB (upload_max_bytes_bom) |
mime is one of application/pdf, image/png, image/jpeg, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet and text/csv. An XLSX is a zip that holds [Content_Types].xml and xl/workbook.xml; a plain zip is refused. A CSV is UTF-8 text with no NUL and no control character other than tab, CR and LF. An upload the virus scanner refuses is never stored, so it has no row.
packet_docs:asset_id,kind,title,added_at,added_by, soft delete.qc_docs:project_id,unit_id,type,rev,asset_id,auto,superseded_by(the newer row),added_at,added_by.itps: one per unit (unit_idunique).templatejsonb, frozen when the ITP is made.status(itp_status),signoff_*(with the signature and the signed PDF asset),reopen_*,version.itp_lines:key(unique per ITP),result,by,at,ncr,note,signature_id,row_version.itp_rejects: append-only.line_key,ncr(required),note,at,by. Every reject keeps its NCR id, even after the line is approved later.itp_blocks:block_key(unique per ITP),signed_at,signed_by,signature_id.
Append-only set
These tables have the ps_append_only row and TRUNCATE triggers, and ps_app holds only SELECT and INSERT on them:
audit_events, signin_attempts, signatures, project_renumbers, short_receipts, packet_transitions, qc_history, packet_log, itp_rejects, co_verifications.
The app role is refused by privilege first. The migrate role owns the tables, so the trigger refuses it. The views v_units and v_open_cos are SELECT only for ps_app.
Audit, settings, jobs (phases 1 and 3)
audit_events(migration 0001): append-only.at,user_id,session_id,role_snapshot(systemfor the worker),action,subject_type,subject_id,packet_id,detailjsonb,it_reason,ip. A hash chain:row_hashis sha256 overprev_hashand the canonical JSON of the row, andprev_hashis the row before it. BRIN index onat.settings(migration 0001):key(text PK),valuejsonb,version,updated_at,updated_by. Phase 3 adds the rowfeed, the feed settings as one JSON object (see FEED.md, "Settings"). Phase 5 adds the rowchecklist_templates(see "Checklist templates"). A missing key takes its default. A save bumpsversionand sendsps_notify('ps_settings', ...)so the worker reschedules.
Phase 3, migration 0004:
job_requests: one row per "Run now".job,requested_at,requested_by,note,claimed_run_idandclaimed_at(set by the run that took it). Index onjobwhereclaimed_atis null. The app inserts the row and NOTIFYsps_job_requestin the same transaction.job_runs: one row per run.job,trigger(schedule, request, chain, cli),requested_byuuid[] (the requests claimed),started_at,finished_at,status(running, ok, degraded, failed, skipped, abandoned),countsjsonb,errorsjsonb (a list of redacted strings),build_version,visual_target("<server> / <database>"),host. Index on (job,id DESC).job_schedule: one row per job (jobPK).next_run_at,interval_seconds,paused,updated_at. Written by the worker after each run for the Admin tab. Seeded forvisual-feed,drawing-lookupandhousekeeping.worker_heartbeat: a single row (id= 1 by CHECK).at,build,host,pid,runningtext[] (the jobs running now). Upserted every 30 seconds.
Notifications (phases 4 and 9)
Phase 4, migration 0005:
notification_events: one row per alert.idbigserial,kind(text, for exampleassets_quota),severity(info,warningorcriticalby CHECK, defaultwarning),subject(what the alert is about, for exampleassets),detailjsonb (for the quota alert, the volume usage:total,used,free,pct,warn),created_at,resolved_at(set when the condition clears),acknowledged_atandacknowledged_by(an Admin saw it). The unique indexnotification_events_openon (kind,coalesce(subject, '')) whereresolved_atis null keeps one open alert per kind and subject, so a check that runs again does not stack rows. The table is not append-only: the app setsresolved_atand the acknowledgement. 0005 also grantsps_appUSAGE and SELECT on every sequence in the schema.
Phase 9 builds the delivery on this table: rules, recipients, exclusions, outbox, sent log, state.
VISUAL and drawing caches (phase 3)
Migration 0004. Every ID part is text (wo_base, lot, split, sub, part IDs, order IDs), with '' in place of null in a key. seq and piece are int. Closed sets are text with a CHECK. The worker fills these tables. Screens read them, never VISUAL. FEED.md says how each is written.
visual_batches: one row per feed run.run_id,started_at,finished_at,server,database,site,sinceanduntil(the window),window_rows,tracked_keys,missing_keys,suspect,countsjsonb.visual_rows: one row per work order operation (TYPE W), unique on (wo_base,lot,split,sub,seq). The contract fields:res,dwg,rev,ps(partspecs),descr,qty,tgt,rel,want,st(the status code F, R, U, C, X or''),part,barcode.row_hash(sha256 of the canonical row),first_batch,last_batchandgone_batch(set when a row in play was not read; cleared when it returns). Indexes on (wo_base,lot), ondwg, and onrelfor rows not gone.visual_materials: one row per material line, unique on (wo_base,lot,split,sub,seq,piece).part(null for a line with no part),qty,issued,whs,loc,st,istat,dwg,descr,comm,cons,mat_hash,first_batch,last_batch,gone_batch. Index on (wo_base,lot).visual_orders: one row per customer order (cust_order_idPK).status,customer_id,customer_name,customer_po_ref,order_date,desired_ship_date,promise_date,last_batch.visual_order_links:kind(base or project),key(the base or the project number),cust_order_id,rank(open first, newest first),linesint[],top_jobstext[],last_batch. Unique on (kind,key,cust_order_id). Replaced for every key read.visual_labor: ticket hours per leg, unique on (wo_base,lot,split,sub,seq).hours,tickets,last_ticket,observed_at. No cost or pay column.visual_row_changes: what VISUAL changed, per batch.batch_id, the key andseq,kind(added, changed, gone, returned),beforeandafterjsonb (the row as text values),at. Index onbatch_id.visual_material_changesis the same pluspiece.visual_ignored_wos: work orders kept out by the go-live date, unique on (wo_base,lot,split,sub).rel,project,unit_number,res,dwg,first_seen_batch,last_seen_at, andpulled_in_at,pulled_in_by,pulled_in_reasonwhen Clerical pulls one in.visual_resource_map:resource(PK),target(fold,ignoreor a two-digit department code, by CHECK),confirmed,updated_at,updated_by. Seeded from the old build'svisual_map.json, every entry unconfirmed. "Electical" is spelled that way in VISUAL.drawing_lookups: one row per drawing number as VISUAL writes it (dwgPK, trimmed). The last answer:checked_at,ok,rev(released),name,obj_id,obj_version,file_id,file_version,file_size,base(H sheet),draft_rev,draft_where,in_drafting,note. Retry state:fail_count,failing_since,next_check_at. The fetched file:asset_id,sha256.visual_rev_seenis the VISUAL rev at the last lookup, so a rev change moves the drawing to the front of the queue. A rev mismatch keeps the staged M-Files copy here for approval.
Column additions in 0004:
packets:visual_cancelled_atandwo_gone_at, so boards can filter on cancelled and gone without readingflags.packet_work_orders:partspecs,revanddwg, the leg's own planner instructions, rev and drawing as VISUAL has them. The header keeps the highest rev. The unique key now includesseq: 0004 drops the 0003 unique on (packet_id,wo_base,lot,split,sub) and addsUNIQUE NULLS NOT DISTINCT (packet_id, wo_base, lot, split, sub, seq), because the feed puts two operations of one work order on one packet. Hand drafts keepseqnull, so they still have one row per key.projects:order_date_visual,ship_date_visual,order_refjsonb (the chosen order and how it was chosen), and the Clerical pinorder_pin(a customer order ID),order_pin_by,order_pin_at,order_pin_reason.units:customer,po,customer_visual,po_visual,order_date_visual,ship_date_visual,order_ref, and the same pin columns.change_orders: nothing new.vis,waiting,staged,target_fpandminorcame in 0003.
Versioning
packets.versiongoes up by one in every write transaction that touches the packet or any of its children. The write pipeline does it explicitly (UPDATE packets SET version = version + 1 ... RETURNING version) after the caller's SQL. No trigger bumps it, so a read never does. It is the packet ETag and the NOTIFY payload.projects.version,units.versionanditps.versionplay the same part for those rows.- Child rows that are edited in place carry
row_version. A PATCH sendsIf-Match: <row_version>. A mismatch is 409row_conflictwith the current row. The write bumpsrow_version. - Every write takes
SELECT ... FOR UPDATEon the packet row first. That lock replaces the old 8-second lease and serialises the counters:next_weld_no,next_heat_no,next_page_noandnext_co_nohand out the next number withUPDATE packets SET next_weld_no = next_weld_no + 1 RETURNING next_weld_no - 1, so two concurrent adds never get the same number. - Transitions do not check
version. They compare and set onexpect: {status, dept}, so a weld edit never blocks a send.