diff --git a/CLAUDE.md b/CLAUDE.md index 2543064..0f8ae44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,8 +68,8 @@ uv run pyinstaller opal.spec # Output: dist/opal ### Test infrastructure - Fixtures in `tests/conftest.py`: in-memory SQLite engine, per-test rollback transactions -- `client` fixture provides `TestClient` with dependency overrides -- `auth_headers` fixture provides `{"X-User-Id": str(test_user.id)}` +- `client` fixture provides `TestClient` pre-authenticated as an admin service user (Bearer token) +- `auth_headers` fixture provides `{"Authorization": "Bearer "}` for `test_user`; `web_client` adds the session cookie for web-page GETs ## Critical Rules @@ -87,9 +87,13 @@ Dense, explicit, functional. Expose state and inner workings. Data tables over c **One fact, one home.** Every other appearance is a live reference, never a copy. Test: if updating something requires touching two places, the design is wrong — delete one occurrence or derive it. PRs that violate this must argue against it by name. -**The empty-state rule.** An empty section is one line, never a box: `{Section} — none · [+ action]`, muted text, 0.5-border row (`ok.empty_line` macro). A section earns vertical space only when populated. Empty states are facts, not features; six boxes announcing nothingness is anti-density. +**The empty-state rule.** Emptiness is information only where content is expected; declared intent decides where it's expected, and data always renders. A part's `procurement` (make | buy | both) declares expectation: BOM expects content on make|both, SUPPLIERS and PO LINES on buy|both, everything else always. An irrelevant empty section is absent — not collapsed, absent — while any section with rows renders regardless of the declaration. An empty relevant section is ONE line — `LABEL — none · + ADD` (`ok.empty_line`) — never a header bar over an empty box, never narration ("No X defined" is banned). -**Voice rules.** Spec prose is rationale for the implementer, never interface copy. (1) No interface copy that explains the interface — absent features are not apologized for. (2) Consequence sentences live only in confirmation dialogs and errors; each such sentence has exactly one home. (3) Labels are nouns, values are facts — no clauses, no narration. (4) Nothing hides behind disclosure: meta fields render always, as dense mono readouts. (5) Width is an information budget — max-width the content or fill the viewport with columns of data, never one stretched sparse column. (6) Context pre-fill: the form never asks what the invoking context already knows. (7) Database ids never render in lists, headers, or titles. +**Structure and register are separate layers.** Structure — panels, header bars, column-headed tables, full-width grids — is the app's shared grammar and may not be deleted by a register pass. Register — row pitch, accent budget, voice, contrast ladder — is where density lives. Data renders in tables: detail rows (`ok.detail_row`) for facts, headed `data-table`s for collections; a register amendment tightens a table's pitch, it does not dissolve the table. The parts list and part page are the reference implementations. + +**Voice rules.** Spec prose is rationale for the implementer, never interface copy. (1) No interface copy that explains the interface — absent features are not apologized for. (2) Consequence sentences live only in confirmation dialogs and errors; each such sentence has exactly one home. (3) Labels are nouns, values are facts — no clauses, no narration. (4) Nothing hides behind disclosure: meta fields render always, as detail-row tables; absence is a muted dash, never an omitted row. (5) Width is an information budget — max-width the content or fill the viewport with columns of data, never one stretched sparse column. (6) Context pre-fill: the form never asks what the invoking context already knows. (7) Database ids never render in lists, headers, or titles. + +**Register rules.** (1) No emoji or pictographs — state words in state colors carry state. Arrows (→ ←), box-drawing rules (──), and geometric chevrons are typography and stay. (2) State badges only where the state is exceptional or actionable — a state badge on every row is information about nothing. (3) Accent color = identifier + action, never content. (4) Consequence renders where it lands, not where it's filed — a column earns its place by the question the page answers. ## Linting (Ruff) diff --git a/OPAL_manual.md b/OPAL_manual.md index 46bfa82..13b6193 100644 --- a/OPAL_manual.md +++ b/OPAL_manual.md @@ -394,6 +394,7 @@ A **Part** in OPAL represents a design-level component - the abstract definition | **Description** | No | Detailed specifications | | **Parent Part** | No | For assemblies - what this is part of | | **Tracking Type** | Yes | BULK (counted by quantity) or SERIALIZED (each item tracked) | +| **Procurement** | Auto | MAKE / BUY / BOTH — defaults to BUY when an External PN is given, else MAKE; decides which part-page sections expect content | | **Metadata** | No | Custom JSON fields for extra data | 4. Click **Create Part** diff --git a/migrations/versions/26297a8e0c76_execution_document_step_focus_issue_.py b/migrations/versions/26297a8e0c76_execution_document_step_focus_issue_.py new file mode 100644 index 0000000..ecd2cd1 --- /dev/null +++ b/migrations/versions/26297a8e0c76_execution_document_step_focus_issue_.py @@ -0,0 +1,116 @@ +"""Execution document: step focus (cursor presence), step images, capture attribution, strict_sequence + +Revision ID: 26297a8e0c76 +Revises: 52973e130a6f +Create Date: 2026-06-12 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '26297a8e0c76' +down_revision: Union[str, None] = '52973e130a6f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Cursor presence — one row per (instance, user), upserted as the cursor + # moves. Ephemeral presence, not history: moving records no event. + op.create_table( + 'step_focus', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('instance_id', sa.Integer(), nullable=False), + sa.Column('step_execution_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('focused_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['instance_id'], ['procedure_instance.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['step_execution_id'], ['step_execution.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('instance_id', 'user_id', name='uq_step_focus_user'), + ) + op.create_index(op.f('ix_step_focus_instance_id'), 'step_focus', ['instance_id'], unique=False) + op.create_index( + op.f('ix_step_focus_step_execution_id'), 'step_focus', ['step_execution_id'], unique=False + ) + op.create_index(op.f('ix_step_focus_user_id'), 'step_focus', ['user_id'], unique=False) + + # Soft telemetry: first cursor focus per step; never rendered as state. + with op.batch_alter_table('step_execution', schema=None) as batch_op: + batch_op.add_column(sa.Column( + 'first_focused_at', sa.DateTime(timezone=True), nullable=True, + comment='Soft telemetry: first cursor focus; never rendered as state', + )) + + + # Authored step reference images — procedure content, snapshotted at publish. + op.create_table( + 'step_image', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('step_id', sa.Integer(), nullable=False), + sa.Column('attachment_id', sa.Integer(), nullable=False), + sa.Column('caption', sa.String(length=255), nullable=True), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['step_id'], ['procedure_step.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['attachment_id'], ['attachment.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_step_image_step_id'), 'step_image', ['step_id'], unique=False) + op.create_index( + op.f('ix_step_image_attachment_id'), 'step_image', ['attachment_id'], unique=False + ) + + # Execution-capture attribution + note on attachments. + with op.batch_alter_table('attachment', schema=None) as batch_op: + batch_op.add_column(sa.Column( + 'note', sa.Text(), nullable=True, + comment="Capturer's note on an execution capture", + )) + batch_op.add_column(sa.Column('uploaded_by_id', sa.Integer(), nullable=True)) + batch_op.create_index( + batch_op.f('ix_attachment_uploaded_by_id'), ['uploaded_by_id'], unique=False + ) + batch_op.create_foreign_key( + 'fk_attachment_uploaded_by_id_user', 'user', ['uploaded_by_id'], ['id'], + ondelete='SET NULL', + ) + + # Opt-in in-order sub-step execution on an OP. + with op.batch_alter_table('procedure_step', schema=None) as batch_op: + batch_op.add_column(sa.Column( + 'strict_sequence', sa.Boolean(), nullable=False, server_default=sa.false(), + comment='OP-level: sub-steps must start in order (N requires N-1 terminal)', + )) + + +def downgrade() -> None: + with op.batch_alter_table('procedure_step', schema=None) as batch_op: + batch_op.drop_column('strict_sequence') + + with op.batch_alter_table('attachment', schema=None) as batch_op: + batch_op.drop_constraint('fk_attachment_uploaded_by_id_user', type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_attachment_uploaded_by_id')) + batch_op.drop_column('uploaded_by_id') + batch_op.drop_column('note') + + op.drop_index(op.f('ix_step_image_attachment_id'), table_name='step_image') + op.drop_index(op.f('ix_step_image_step_id'), table_name='step_image') + op.drop_table('step_image') + + + with op.batch_alter_table('step_execution', schema=None) as batch_op: + batch_op.drop_column('first_focused_at') + + op.drop_index(op.f('ix_step_focus_user_id'), table_name='step_focus') + op.drop_index(op.f('ix_step_focus_step_execution_id'), table_name='step_focus') + op.drop_index(op.f('ix_step_focus_instance_id'), table_name='step_focus') + op.drop_table('step_focus') diff --git a/migrations/versions/52973e130a6f_merge_issues_r2_and_risk_scenarios_heads.py b/migrations/versions/52973e130a6f_merge_issues_r2_and_risk_scenarios_heads.py new file mode 100644 index 0000000..1d76f18 --- /dev/null +++ b/migrations/versions/52973e130a6f_merge_issues_r2_and_risk_scenarios_heads.py @@ -0,0 +1,23 @@ +"""merge issues r2 and risk scenarios heads + +Revision ID: 52973e130a6f +Revises: a7b9c1d3e5f7, a7c41d92e803 +Create Date: 2026-06-12 12:27:17.477587 + +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "52973e130a6f" +down_revision: str | Sequence[str] | None = ("a7b9c1d3e5f7", "a7c41d92e803") +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/migrations/versions/894e35219894_merge_exec_focus_and_part_procurement_.py b/migrations/versions/894e35219894_merge_exec_focus_and_part_procurement_.py new file mode 100644 index 0000000..0377122 --- /dev/null +++ b/migrations/versions/894e35219894_merge_exec_focus_and_part_procurement_.py @@ -0,0 +1,26 @@ +"""merge exec focus and part procurement heads + +Revision ID: 894e35219894 +Revises: b3d1f0a2c4e6, b3c5d7e9f1a2 +Create Date: 2026-06-12 14:33:14.188460 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '894e35219894' +down_revision: Union[str, None] = ('b3d1f0a2c4e6', 'b3c5d7e9f1a2') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/migrations/versions/a7c41d92e803_issue_containment_and_disposition.py b/migrations/versions/a7c41d92e803_issue_containment_and_disposition.py new file mode 100644 index 0000000..dd6b9de --- /dev/null +++ b/migrations/versions/a7c41d92e803_issue_containment_and_disposition.py @@ -0,0 +1,122 @@ +"""Issue containment and disposition signature + +Issues R2: the blocking predicate becomes `undispositioned`, never `open`. +- containment (step|op|wo|advisory) + containment_step_id: what an issue holds +- raised_step_id (renamed from step_execution_id) + raised_by_id: where/who found it +- disposition signature: dispositioned_by_id (renamed from + disposition_approved_by_id) + dispositioned_at +- disposition_rationale (renamed from disposition_notes) +- actual (renamed from is_condition) — the should-be/is capture pair +- IssueStatus collapses to open|closed; disposition state is derived +- DispositionType moves to the MIL-STD-1520 set (return_to_vendor → + return_to_supplier; repair and no_defect added) +- step_execution.on_hold rows revert to in_progress: holds are now derived + from undispositioned issues, never stored on the step + +Revision ID: a7c41d92e803 +Revises: ccf93c36170a +Create Date: 2026-06-12 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a7c41d92e803" +down_revision: Union[str, None] = "ccf93c36170a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Issue table: new containment/signature columns + renames + with op.batch_alter_table("issue") as batch_op: + batch_op.add_column( + sa.Column("containment", sa.String(20), nullable=False, server_default="advisory") + ) + batch_op.add_column(sa.Column("containment_step_id", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("raised_by_id", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("dispositioned_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.alter_column("step_execution_id", new_column_name="raised_step_id") + batch_op.alter_column("is_condition", new_column_name="actual") + batch_op.alter_column("disposition_notes", new_column_name="disposition_rationale") + batch_op.alter_column("disposition_approved_by_id", new_column_name="dispositioned_by_id") + batch_op.create_foreign_key( + "fk_issue_containment_step", + "step_execution", + ["containment_step_id"], + ["id"], + ondelete="SET NULL", + ) + batch_op.create_foreign_key( + "fk_issue_raised_by", "user", ["raised_by_id"], ["id"], ondelete="SET NULL" + ) + + # The index on the renamed column survives the batch rebuild under its old + # name; rename it to match the column. + op.execute("DROP INDEX IF EXISTS ix_issue_step_execution_id") + op.create_index("ix_issue_raised_step_id", "issue", ["raised_step_id"]) + + # 2. Data migration + # Disposition enum: vendor → supplier (MIL-STD-1520 set) + op.execute( + "UPDATE issue SET disposition_type = 'return_to_supplier' " + "WHERE disposition_type = 'return_to_vendor'" + ) + # Issues previously approved get a best-effort signature timestamp so the + # derived `dispositioned` gate stays true for them. + op.execute( + "UPDATE issue SET dispositioned_at = updated_at " + "WHERE status = 'disposition_approved' AND disposition_type IS NOT NULL " + "AND dispositioned_at IS NULL" + ) + # Execution-raised NCs held their step under the old model; preserve that + # as step containment (dispositioned/closed ones don't block anyway). + op.execute( + "UPDATE issue SET containment = 'step' " + "WHERE issue_type = 'non_conformance' AND raised_step_id IS NOT NULL" + ) + # Status collapses to open|closed. + op.execute( + "UPDATE issue SET status = 'open' " + "WHERE status IN ('investigating', 'disposition_pending', 'disposition_approved')" + ) + + # 3. Step holds are derived now — stored ON_HOLD rows revert to in_progress; + # their undispositioned issues keep blocking via containment. + op.execute("UPDATE step_execution SET status = 'in_progress' WHERE status = 'on_hold'") + + +def downgrade() -> None: + # Best-effort status reconstruction: signed dispositions map back to + # disposition_approved; everything else stays open. + op.execute( + "UPDATE issue SET status = 'disposition_approved' " + "WHERE status = 'open' AND disposition_type IS NOT NULL AND dispositioned_at IS NOT NULL" + ) + op.execute( + "UPDATE issue SET disposition_type = 'return_to_vendor' " + "WHERE disposition_type = 'return_to_supplier'" + ) + op.execute( + "UPDATE issue SET disposition_type = 'other' " + "WHERE disposition_type IN ('repair', 'no_defect')" + ) + + op.drop_index("ix_issue_raised_step_id", table_name="issue") + + with op.batch_alter_table("issue") as batch_op: + batch_op.drop_constraint("fk_issue_raised_by", type_="foreignkey") + batch_op.drop_constraint("fk_issue_containment_step", type_="foreignkey") + batch_op.alter_column("dispositioned_by_id", new_column_name="disposition_approved_by_id") + batch_op.alter_column("disposition_rationale", new_column_name="disposition_notes") + batch_op.alter_column("actual", new_column_name="is_condition") + batch_op.alter_column("raised_step_id", new_column_name="step_execution_id") + batch_op.drop_column("dispositioned_at") + batch_op.drop_column("raised_by_id") + batch_op.drop_column("containment_step_id") + batch_op.drop_column("containment") + + op.create_index("ix_issue_step_execution_id", "issue", ["step_execution_id"]) diff --git a/migrations/versions/b3c5d7e9f1a2_part_procurement.py b/migrations/versions/b3c5d7e9f1a2_part_procurement.py new file mode 100644 index 0000000..655ec04 --- /dev/null +++ b/migrations/versions/b3c5d7e9f1a2_part_procurement.py @@ -0,0 +1,51 @@ +"""Part procurement declaration (Amendment 6) + +Revision ID: b3c5d7e9f1a2 +Revises: 52973e130a6f +Create Date: 2026-06-12 19:00:00.000000 + +Part gains procurement: make | buy | both. The declaration governs which +part-page sections expect content (BOM for make, suppliers/POs for buy); +data always renders regardless. Backfill uses the creation heuristic: +an external PN means the part was bought, otherwise it is made here. + +Known asymmetry: Onshape-synced parts store the CAD part number in +external_pn, so the backfill marks pre-existing synced parts buy while +the sync code creates new ones as make (deliberately — a CAD reference +is not a vendor PN). Re-declare affected parts via the edit form. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'b3c5d7e9f1a2' +down_revision: Union[str, None] = '52973e130a6f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('part') as batch_op: + batch_op.add_column( + sa.Column('procurement', sa.String(length=10), nullable=False, server_default='make') + ) + batch_op.create_index('ix_part_procurement', ['procurement']) + + part = sa.table( + 'part', + sa.column('procurement', sa.String), + sa.column('external_pn', sa.String), + ) + op.execute( + part.update() + .where(sa.and_(part.c.external_pn.is_not(None), part.c.external_pn != '')) + .values(procurement='buy') + ) + + +def downgrade() -> None: + with op.batch_alter_table('part') as batch_op: + batch_op.drop_index('ix_part_procurement') + batch_op.drop_column('procurement') diff --git a/migrations/versions/b3d1f0a2c4e6_rename_instance_statuses_cut_in_work.py b/migrations/versions/b3d1f0a2c4e6_rename_instance_statuses_cut_in_work.py new file mode 100644 index 0000000..ce71e5a --- /dev/null +++ b/migrations/versions/b3d1f0a2c4e6_rename_instance_statuses_cut_in_work.py @@ -0,0 +1,36 @@ +"""Rename instance statuses: pending -> cut, in_progress -> in_work + +Work orders are "cut" from a master procedure; untouched instances are +CUT, started instances are IN WORK. Step statuses are unchanged. + +Revision ID: b3d1f0a2c4e6 +Revises: 26297a8e0c76 +Create Date: 2026-06-12 12:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'b3d1f0a2c4e6' +down_revision: Union[str, None] = '26297a8e0c76' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_instance = sa.table('procedure_instance', sa.column('status', sa.String)) + + +def upgrade() -> None: + op.execute(_instance.update().where(_instance.c.status == 'pending').values(status='cut')) + op.execute( + _instance.update().where(_instance.c.status == 'in_progress').values(status='in_work') + ) + + +def downgrade() -> None: + op.execute(_instance.update().where(_instance.c.status == 'cut').values(status='pending')) + op.execute( + _instance.update().where(_instance.c.status == 'in_work').values(status='in_progress') + ) diff --git a/migrations/versions/c7a2e9d41f88_step_notes_timestamped_append_only.py b/migrations/versions/c7a2e9d41f88_step_notes_timestamped_append_only.py new file mode 100644 index 0000000..a175663 --- /dev/null +++ b/migrations/versions/c7a2e9d41f88_step_notes_timestamped_append_only.py @@ -0,0 +1,77 @@ +"""Step notes: timestamped, authored, append-only + +Replaces the single free-text step_execution.notes blob with step_note rows +(one fact, one home — the column is dropped, not kept alongside). Each +existing non-empty blob backfills as ONE note: author unknown (NULL), +created_at = the step's completed_at when present, else the migration moment. + +Revision ID: c7a2e9d41f88 +Revises: 894e35219894 +Create Date: 2026-07-05 00:00:00.000000 + +""" +from datetime import UTC, datetime +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c7a2e9d41f88" +down_revision: Union[str, None] = "894e35219894" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "step_note", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("step_execution_id", sa.Integer(), nullable=False), + sa.Column("author_id", sa.Integer(), nullable=True), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["step_execution_id"], ["step_execution.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["author_id"], ["user.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_step_note_step_execution_id"), "step_note", ["step_execution_id"], unique=False + ) + + # Backfill: each non-empty blob becomes one note. Match the SQLite + # DateTime storage format used by SQLAlchemy so mixed literals compare. + now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S.%f") + op.execute( + sa.text( + "INSERT INTO step_note " + "(step_execution_id, author_id, body, created_at, updated_at) " + "SELECT id, NULL, notes, COALESCE(completed_at, :now), " + "COALESCE(completed_at, :now) " + "FROM step_execution WHERE notes IS NOT NULL AND TRIM(notes) != ''" + ).bindparams(now=now) + ) + + with op.batch_alter_table("step_execution", schema=None) as batch_op: + batch_op.drop_column("notes") + + +def downgrade() -> None: + with op.batch_alter_table("step_execution", schema=None) as batch_op: + batch_op.add_column( + sa.Column("notes", sa.Text(), nullable=True, comment="Free-text operator notes") + ) + + # Best-effort: concatenate each step's notes back into the blob + # (group_concat follows rowid order, which matches append order). + op.execute( + "UPDATE step_execution SET notes = (" + "SELECT group_concat(body, char(10)) FROM step_note " + "WHERE step_note.step_execution_id = step_execution.id" + ") WHERE EXISTS (" + "SELECT 1 FROM step_note WHERE step_note.step_execution_id = step_execution.id)" + ) + + op.drop_index(op.f("ix_step_note_step_execution_id"), table_name="step_note") + op.drop_table("step_note") diff --git a/migrations/versions/d4e8b7a2c5f1_drop_procedure_instance_target_entity.py b/migrations/versions/d4e8b7a2c5f1_drop_procedure_instance_target_entity.py new file mode 100644 index 0000000..1a4459c --- /dev/null +++ b/migrations/versions/d4e8b7a2c5f1_drop_procedure_instance_target_entity.py @@ -0,0 +1,33 @@ +"""Drop procedure_instance.target_entity + +The hand-typed TARGET ENTITY inputs were cut from the WO form per rehearsal +feedback (2026-07-06); nothing writes the column and nothing reads it — +genealogy links executions through InventoryConsumption/InventoryProduction +foreign keys, not this JSON blob. Data loss on upgrade is accepted: the only +values ever written came from the removed form fields. + +Revision ID: d4e8b7a2c5f1 +Revises: c7a2e9d41f88 +Create Date: 2026-07-06 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d4e8b7a2c5f1" +down_revision: Union[str, None] = "c7a2e9d41f88" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("procedure_instance", schema=None) as batch_op: + batch_op.drop_column("target_entity") + + +def downgrade() -> None: + with op.batch_alter_table("procedure_instance", schema=None) as batch_op: + batch_op.add_column(sa.Column("target_entity", sa.JSON(), nullable=True)) diff --git a/opal.spec b/opal.spec index 8d5eea8..a1bd714 100644 --- a/opal.spec +++ b/opal.spec @@ -10,6 +10,8 @@ Output: dist/opal (or dist/opal.exe on Windows) import os from pathlib import Path +from PyInstaller.utils.hooks import collect_data_files + block_cipher = None # Paths @@ -26,12 +28,23 @@ datas.append((str(src_dir / "web" / "static"), "opal/web/static")) # SE requirement lint rules (loaded as data by opal.se.lint) datas.append((str(src_dir / "se" / "lint_rules.yaml"), "opal/se")) +# Risk scenario lint rules (loaded as data by opal.risks.lint; the demo seed's +# risk-accept path reads them) +datas.append((str(src_dir / "risks" / "lint_rules.yaml"), "opal/risks")) + +# Mojave Sphinx demo seed data (loaded by opal.seed) +datas.append((str(src_dir / "seed_data" / "sphinx"), "opal/seed_data/sphinx")) + # TUI styles datas.append((str(src_dir / "tui" / "styles.tcss"), "opal/tui")) # Launcher styles datas.append((str(src_dir / "launcher.tcss"), "opal")) +# Third-party package data: fido2 (WebAuthn) reads public_suffix_list.dat at +# import time — the frozen server dies without it. +datas.extend(collect_data_files("fido2")) + # Alembic migrations (for programmatic upgrades) datas.append((str(migrations_dir), "migrations")) diff --git a/src/opal/__main__.py b/src/opal/__main__.py index bffc42f..e8e08a3 100644 --- a/src/opal/__main__.py +++ b/src/opal/__main__.py @@ -154,20 +154,43 @@ def cmd_migrate(args: argparse.Namespace) -> None: def cmd_seed(args: argparse.Namespace) -> None: - """Populate database with Project Kestrel demo data.""" + """Populate database with Mojave Sphinx demo data.""" _setup_project(args) + from opal.config import PROJECT_CONFIG_KEY, get_app_setting from opal.db.base import SessionLocal - from opal.db.models import Part + from opal.db.models import Part, Supplier, User, Workcenter from opal.seed import seed_database db = SessionLocal() try: - if db.query(Part).first(): - print("Database already has data. Skipping seed.") - return + # The seed OVERWRITES the project config and inserts demo users with a + # publicly documented password — never on top of a configured instance. + found = [] + for model, label in ( + (Part, "parts"), + (User, "users"), + (Workcenter, "workcenters"), + (Supplier, "suppliers"), + ): + count = db.query(model).count() + if count: + found.append(f"{count} {label}") + if get_app_setting(db, PROJECT_CONFIG_KEY) is not None: + found.append("a saved project config") + + if found and not args.force: + print( + "Refusing to seed: this database already has " + + ", ".join(found) + + ".\nSeeding would overwrite the project config and add demo users " + "with a publicly documented password.\n" + "Use --force to seed anyway.", + file=sys.stderr, + ) + sys.exit(1) - print("Seeding Project Kestrel data...") + print("Seeding Mojave Sphinx data...") seed_database(db) print("Done.") finally: @@ -391,6 +414,11 @@ def add_project_args(p: argparse.ArgumentParser) -> None: # seed command seed_parser = subparsers.add_parser("seed", help="Seed demo data") + seed_parser.add_argument( + "--force", + action="store_true", + help="Seed even if the database already has users/parts/config (overwrites project config)", + ) add_project_args(seed_parser) seed_parser.set_defaults(func=cmd_seed) diff --git a/src/opal/api/routes/attachments.py b/src/opal/api/routes/attachments.py index 00ecf8e..ed60006 100644 --- a/src/opal/api/routes/attachments.py +++ b/src/opal/api/routes/attachments.py @@ -17,7 +17,14 @@ router = APIRouter(prefix="/attachments", tags=["attachments"]) -ALLOWED_KINDS: set[str | None] = {None, "inline", "reference", "closeout"} +ALLOWED_KINDS: set[str | None] = { + None, + "inline", + "reference", + "closeout", + "capture", + "step_image", +} class AttachmentResponse(BaseModel): @@ -33,6 +40,9 @@ class AttachmentResponse(BaseModel): issue_id: int | None = None procedure_id: int | None = None kind: str | None = None + note: str | None = None + uploaded_by_id: int | None = None + uploaded_by_name: str | None = None created_at: str model_config = {"from_attributes": True} @@ -56,6 +66,9 @@ def _attachment_to_response(att: Attachment) -> AttachmentResponse: issue_id=att.issue_id, procedure_id=att.procedure_id, kind=att.kind, + note=att.note, + uploaded_by_id=att.uploaded_by_id, + uploaded_by_name=att.uploaded_by.name if att.uploaded_by else None, created_at=att.created_at.isoformat(), ) @@ -70,6 +83,7 @@ async def upload_attachment( issue_id: int | None = Form(default=None), procedure_id: int | None = Form(default=None), kind: str | None = Form(default=None), + note: str | None = Form(default=None), ) -> AttachmentResponse: """Upload a file attachment. @@ -150,6 +164,13 @@ async def upload_attachment( file_path = settings.upload_dir / stored_name file_path.write_bytes(content) + # Captures linked only to a step inherit the step's instance so + # instance-level listings (rail, build report) see them. + if step_execution_id and not procedure_instance_id: + step_exec = db.query(StepExecution).filter(StepExecution.id == step_execution_id).first() + if step_exec: + procedure_instance_id = step_exec.instance_id + # Create DB record attachment = Attachment( original_filename=original_name, @@ -161,6 +182,8 @@ async def upload_attachment( issue_id=issue_id, procedure_id=procedure_id, kind=kind, + note=note, + uploaded_by_id=user_id, ) db.add(attachment) db.flush() diff --git a/src/opal/api/routes/execution.py b/src/opal/api/routes/execution.py index ce98f9d..61579bb 100644 --- a/src/opal/api/routes/execution.py +++ b/src/opal/api/routes/execution.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field +from sqlalchemy.orm import joinedload, selectinload from opal.api.deps import CurrentUserId, DbSession from opal.core.audit import get_model_dict, log_create, log_update @@ -16,20 +17,34 @@ generate_work_order_number, ) from opal.core.events import ( + emit_cursor_moved, emit_instance_completed, emit_instance_started, emit_step_completed, - emit_step_started, emit_user_joined, emit_user_left, ) +from opal.core.execution_flow import ( + FlowError, + add_step_note, + build_execution_state, + check_instance_completion, + clear_focus, + complete_step_flow, + focus_step, + mark_instance_in_work, + skip_blockers, + touch_user_presence, +) from opal.core.genealogy import record_assembly_genealogy +from opal.core.holds import get_hold_state, get_holds_payload from opal.core.part_lifecycle import ensure_parts_active from opal.db.models import InventoryRecord, Kit, Part, ProcedureOutput from opal.db.models.execution import ( InstanceStatus, ProcedureInstance, StepExecution, + StepNote, StepStatus, ) from opal.db.models.inventory import ( @@ -40,7 +55,13 @@ SourceType, UsageType, ) -from opal.db.models.issue import Issue, IssuePriority, IssueStatus, IssueType +from opal.db.models.issue import ( + Containment, + Issue, + IssuePriority, + IssueStatus, + IssueType, +) from opal.db.models.procedure import MasterProcedure, ProcedureType, ProcedureVersion router = APIRouter(prefix="/procedure-instances", tags=["execution"]) @@ -49,6 +70,16 @@ # ============ Schemas ============ +class StepNoteResponse(BaseModel): + """One timestamped, authored, append-only step note.""" + + id: int + author_id: int | None = None + author: str | None = None + created_at: datetime + body: str + + class StepExecutionResponse(BaseModel): """Step execution response.""" @@ -62,7 +93,7 @@ class StepExecutionResponse(BaseModel): started_at: datetime | None = None completed_at: datetime | None = None completed_by_id: int | None = None - notes: str | None = None + notes: list[StepNoteResponse] = [] signed_off_at: datetime | None = None signed_off_by_id: int | None = None duration_seconds: int | None = None @@ -70,6 +101,36 @@ class StepExecutionResponse(BaseModel): model_config = {"from_attributes": True} +def _note_response(note: StepNote) -> StepNoteResponse: + return StepNoteResponse( + id=note.id, + author_id=note.author_id, + author=note.author.name if note.author else None, + created_at=note.created_at, + body=note.body, + ) + + +def _step_response(step_exec: StepExecution) -> StepExecutionResponse: + """The one serializer for a step execution row.""" + return StepExecutionResponse( + id=step_exec.id, + step_number=step_exec.step_number, + step_number_str=step_exec.step_number_str, + level=step_exec.level, + parent_step_order=step_exec.parent_step_order, + status=step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status, + data_captured=step_exec.data_captured, + started_at=step_exec.started_at, + completed_at=step_exec.completed_at, + completed_by_id=step_exec.completed_by_id, + notes=[_note_response(n) for n in step_exec.notes], + signed_off_at=step_exec.signed_off_at, + signed_off_by_id=step_exec.signed_off_by_id, + duration_seconds=step_exec.duration_seconds, + ) + + class InstanceResponse(BaseModel): """Procedure instance response.""" @@ -87,7 +148,6 @@ class InstanceResponse(BaseModel): scheduled_start_at: datetime | None = None target_completion_at: datetime | None = None priority: int = 0 - target_entity: dict[str, Any] | None = None created_at: datetime step_executions: list[StepExecutionResponse] = [] @@ -109,14 +169,12 @@ class InstanceCreate(BaseModel): procedure_id: int version_id: int | None = Field(None, description="If not provided, uses current version") work_order_number: str | None = None + quantity: int = Field( + 1, ge=1, le=100, description="Number of identical work orders to cut (batch cut)" + ) scheduled_start_at: datetime | None = None target_completion_at: datetime | None = None priority: int = 0 - target_entity: dict[str, Any] | None = Field( - None, - description="Entity this execution targets, e.g. " - '{"entity_type": "part", "entity_id": 42, "entity_label": "SN-00042"}', - ) class InstanceUpdate(BaseModel): @@ -143,11 +201,33 @@ class StepComplete(BaseModel): class NonConformanceCreate(BaseModel): - """Log non-conformance during step execution.""" + """Capture an issue at the moment of discovery during step execution. + + The invoking context already knows WO, OP, step, and operator — the + payload only carries what the crew observed. containment decides what + the issue holds while undispositioned (step | op | wo | advisory); an + optional boundary ("resolve by") binds a step-contained hold to a later + step, which cannot COMPLETE until disposition. + """ title: str = Field(..., min_length=1, max_length=255) description: str | None = None priority: str = "medium" + should_be: str | None = None + actual: str | None = None + containment: str = "step" + containment_step_number: int | None = Field( + None, + description="Boundary step order ('resolve by'); None anchors at the raised step", + ) + assigned_to_id: int | None = None + + +def _hold_blocker_detail(action: str, blockers: list[Issue]) -> str: + """400 detail naming the holding issue(s) — the gated control's reason + line, server side.""" + names = ", ".join(f"{i.issue_number} {i.title}" for i in blockers) + return f"Cannot {action}: held by {names} (undispositioned)" # ============ Instance CRUD ============ @@ -174,16 +254,37 @@ def list_instances( total = query.count() + # _step_response serializes every step's notes and each note's author: + # eager-load the whole chain (plus the procedure name) so the list stays + # a fixed number of queries — the TUI refresh degraded O(steps+notes) + # via per-row lazy loads (F10). instances = ( - query.order_by(ProcedureInstance.id.desc()) + query.options( + joinedload(ProcedureInstance.procedure), + selectinload(ProcedureInstance.step_executions) + .selectinload(StepExecution.notes) + .joinedload(StepNote.author), + ) + .order_by(ProcedureInstance.id.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) + # One query for the page's version numbers, not one per instance. + version_ids = {inst.version_id for inst in instances} + versions_by_id = ( + { + v.id: v + for v in db.query(ProcedureVersion).filter(ProcedureVersion.id.in_(version_ids)).all() + } + if version_ids + else {} + ) + items = [] for inst in instances: - version = db.query(ProcedureVersion).filter(ProcedureVersion.id == inst.version_id).first() + version = versions_by_id.get(inst.version_id) items.append( InstanceResponse( id=inst.id, @@ -200,27 +301,8 @@ def list_instances( scheduled_start_at=inst.scheduled_start_at, target_completion_at=inst.target_completion_at, priority=inst.priority, - target_entity=inst.target_entity, created_at=inst.created_at, - step_executions=[ - StepExecutionResponse( - id=se.id, - step_number=se.step_number, - step_number_str=se.step_number_str, - level=se.level, - parent_step_order=se.parent_step_order, - status=se.status.value if hasattr(se.status, "value") else se.status, - data_captured=se.data_captured, - started_at=se.started_at, - completed_at=se.completed_at, - completed_by_id=se.completed_by_id, - signed_off_at=se.signed_off_at, - notes=se.notes, - signed_off_by_id=se.signed_off_by_id, - duration_seconds=se.duration_seconds, - ) - for se in inst.step_executions - ], + step_executions=[_step_response(se) for se in inst.step_executions], ) ) @@ -238,7 +320,11 @@ def create_instance( db: DbSession, user_id: CurrentUserId, ) -> InstanceResponse: - """Start a new procedure instance.""" + """Cut work order(s) from a procedure version. + + quantity > 1 cuts a batch of identical work orders, each with its own + generated WO number; the response carries the first cut. + """ # Validate procedure exists procedure = ( db.query(MasterProcedure) @@ -263,66 +349,70 @@ def create_instance( .first() ) - # Generate work order number if not provided - work_order_number = data.work_order_number or generate_work_order_number(db) - - # Create instance - instance = ProcedureInstance( - procedure_id=data.procedure_id, - version_id=version.id, - work_order_number=work_order_number, - status=InstanceStatus.PENDING, - started_by_id=user_id, - scheduled_start_at=data.scheduled_start_at, - target_completion_at=data.target_completion_at, - priority=data.priority, - target_entity=data.target_entity, - ) - db.add(instance) - db.flush() + if data.work_order_number and data.quantity > 1: + raise HTTPException( + status_code=400, + detail="Explicit work order number only applies to a single cut", + ) - # Create step executions from version snapshot, preserving hierarchy + # Step hierarchy from the version snapshot — shared by every cut. steps = version.content.get("steps", []) - - # Build a map of step order -> parent step order for hierarchy order_to_parent: dict[int, int | None] = {} for step in steps: parent_id = step.get("parent_step_id") if parent_id: - # Find parent's order parent_step = next((s for s in steps if s.get("id") == parent_id), None) order_to_parent[step["order"]] = parent_step["order"] if parent_step else None else: order_to_parent[step["order"]] = None - for step in steps: - step_exec = StepExecution( - instance_id=instance.id, - step_number=step["order"], - step_number_str=step.get("step_number", str(step["order"])), - level=step.get("level", 0), - parent_step_order=order_to_parent.get(step["order"]), - status=StepStatus.PENDING, - ) - db.add(step_exec) - - # Auto-allocate output assemblies for BUILD procedures + # BUILD procedures auto-allocate output assemblies. An as-built + # allocation is a physical reference — draft output parts block the + # whole cut (raises DraftPartsBlocked -> 409) before anything exists. proc_type = procedure.procedure_type if hasattr(proc_type, "value"): proc_type = proc_type.value + outputs = [] if proc_type == ProcedureType.BUILD.value: outputs = ( db.query(ProcedureOutput) .filter(ProcedureOutput.procedure_id == data.procedure_id) .all() ) - # An as-built allocation is a physical reference — draft output - # parts block it (raises DraftPartsBlocked -> 409) ensure_parts_active( db, [output.part_id for output in outputs], - f"work order {work_order_number} as-built allocation", + f"{procedure.name} as-built allocation", ) + + first_instance: ProcedureInstance | None = None + for _ in range(data.quantity): + work_order_number = data.work_order_number or generate_work_order_number(db) + + instance = ProcedureInstance( + procedure_id=data.procedure_id, + version_id=version.id, + work_order_number=work_order_number, + status=InstanceStatus.CUT, + started_by_id=user_id, + scheduled_start_at=data.scheduled_start_at, + target_completion_at=data.target_completion_at, + priority=data.priority, + ) + db.add(instance) + db.flush() + + for step in steps: + step_exec = StepExecution( + instance_id=instance.id, + step_number=step["order"], + step_number_str=step.get("step_number", str(step["order"])), + level=step.get("level", 0), + parent_step_order=order_to_parent.get(step["order"]), + status=StepStatus.PENDING, + ) + db.add(step_exec) + for output in outputs: output_part = db.query(Part).filter(Part.id == output.part_id).first() if not output_part: @@ -359,7 +449,11 @@ def create_instance( # Link inventory record to its production inv_record.source_production_id = production.id - log_create(db, instance, user_id) + log_create(db, instance, user_id) + if first_instance is None: + first_instance = instance + + instance = first_instance db.commit() db.refresh(instance) @@ -378,26 +472,8 @@ def create_instance( scheduled_start_at=instance.scheduled_start_at, target_completion_at=instance.target_completion_at, priority=instance.priority, - target_entity=instance.target_entity, created_at=instance.created_at, - step_executions=[ - StepExecutionResponse( - id=se.id, - step_number=se.step_number, - step_number_str=se.step_number_str, - level=se.level, - parent_step_order=se.parent_step_order, - status=se.status.value if hasattr(se.status, "value") else se.status, - data_captured=se.data_captured, - started_at=se.started_at, - completed_at=se.completed_at, - completed_by_id=se.completed_by_id, - signed_off_at=se.signed_off_at, - signed_off_by_id=se.signed_off_by_id, - duration_seconds=se.duration_seconds, - ) - for se in instance.step_executions - ], + step_executions=[_step_response(se) for se in instance.step_executions], ) @@ -428,26 +504,8 @@ def get_instance( scheduled_start_at=instance.scheduled_start_at, target_completion_at=instance.target_completion_at, priority=instance.priority, - target_entity=instance.target_entity, created_at=instance.created_at, - step_executions=[ - StepExecutionResponse( - id=se.id, - step_number=se.step_number, - step_number_str=se.step_number_str, - level=se.level, - parent_step_order=se.parent_step_order, - status=se.status.value if hasattr(se.status, "value") else se.status, - data_captured=se.data_captured, - started_at=se.started_at, - completed_at=se.completed_at, - completed_by_id=se.completed_by_id, - signed_off_at=se.signed_off_at, - signed_off_by_id=se.signed_off_by_id, - duration_seconds=se.duration_seconds, - ) - for se in instance.step_executions - ], + step_executions=[_step_response(se) for se in instance.step_executions], ) @@ -468,16 +526,25 @@ def update_instance( if data.status is not None: try: new_status = InstanceStatus(data.status) - instance.status = new_status - - # Set timestamps based on status - if new_status == InstanceStatus.IN_PROGRESS and not instance.started_at: - instance.started_at = datetime.now(UTC) - elif new_status in [InstanceStatus.COMPLETED, InstanceStatus.ABORTED]: - instance.completed_at = datetime.now(UTC) except ValueError as err: raise HTTPException(status_code=400, detail=f"Invalid status: {data.status}") from err + # Status is derived from events (first COMPLETE/SKIP starts, last + # completion completes); the only settable transition is the abort. + current = instance.status.value if hasattr(instance.status, "value") else instance.status + if new_status != instance.status: + if new_status != InstanceStatus.ABORTED or current not in ( + InstanceStatus.CUT.value, + InstanceStatus.IN_WORK.value, + ): + raise HTTPException( + status_code=400, + detail=f"Cannot set status {current} -> {new_status.value}; " + "status is derived from events, only an active work order can be aborted", + ) + instance.status = new_status + instance.completed_at = datetime.now(UTC) + if data.work_order_number is not None: instance.work_order_number = data.work_order_number @@ -510,196 +577,77 @@ def update_instance( scheduled_start_at=instance.scheduled_start_at, target_completion_at=instance.target_completion_at, priority=instance.priority, - target_entity=instance.target_entity, created_at=instance.created_at, - step_executions=[ - StepExecutionResponse( - id=se.id, - step_number=se.step_number, - step_number_str=se.step_number_str, - level=se.level, - parent_step_order=se.parent_step_order, - status=se.status.value if hasattr(se.status, "value") else se.status, - data_captured=se.data_captured, - started_at=se.started_at, - completed_at=se.completed_at, - completed_by_id=se.completed_by_id, - signed_off_at=se.signed_off_at, - signed_off_by_id=se.signed_off_by_id, - duration_seconds=se.duration_seconds, - ) - for se in instance.step_executions - ], + step_executions=[_step_response(se) for se in instance.step_executions], ) # ============ Step Execution ============ -@router.post("/{instance_id}/steps/{step_number}/start", response_model=StepExecutionResponse) -async def start_step( +class FocusMove(BaseModel): + """Move the caller's cursor to a step. Presence, not an event.""" + + step_number: int + + +@router.post("/{instance_id}/focus") +async def move_focus( instance_id: int, - step_number: int, + data: FocusMove, db: DbSession, user_id: CurrentUserId, -) -> StepExecutionResponse: - """Start a step execution.""" +) -> dict: + """Move the caller's cursor. Broadcast to the document; never recorded.""" instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() if not instance: raise HTTPException(status_code=404, detail="Instance not found") - # Check instance is in progress - status_val = instance.status.value if hasattr(instance.status, "value") else instance.status - if status_val not in [InstanceStatus.PENDING.value, InstanceStatus.IN_PROGRESS.value]: - raise HTTPException(status_code=400, detail="Instance is not active") - - # Start instance if pending - if status_val == InstanceStatus.PENDING.value: - instance.status = InstanceStatus.IN_PROGRESS - instance.started_at = datetime.now(UTC) - - # Transition planned production records to WIP - db.query(InventoryProduction).filter( - InventoryProduction.procedure_instance_id == instance_id, - InventoryProduction.status == ProductionStatus.PLANNED, - ).update({InventoryProduction.status: ProductionStatus.WIP}) - step_exec = ( db.query(StepExecution) - .filter(StepExecution.instance_id == instance_id, StepExecution.step_number == step_number) + .filter( + StepExecution.instance_id == instance_id, + StepExecution.step_number == data.step_number, + ) .first() ) if not step_exec: raise HTTPException(status_code=404, detail="Step not found") - step_status = step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status - if step_status != StepStatus.PENDING.value: - raise HTTPException(status_code=400, detail="Step already started or completed") - - # Gating. We evaluate against the op-level entry — for sub-steps, that's - # the parent op. A sub-step inside a gated/held op must not start. - gate_op_order: int | None = None - if step_exec.level == 0: - gate_op_order = step_number - elif step_exec.parent_step_order is not None: - gate_op_order = step_exec.parent_step_order - # Refuse if the parent op is on hold (NC open on a sibling sub-step). - parent_exec = next( - ( - se - for se in instance.step_executions - if se.step_number == gate_op_order and se.level == 0 - ), - None, - ) - if parent_exec is not None: - parent_status = ( - parent_exec.status.value - if hasattr(parent_exec.status, "value") - else parent_exec.status - ) - if parent_status == StepStatus.ON_HOLD.value: - raise HTTPException( - status_code=400, - detail="Cannot start: parent OP is on hold (open NC)", - ) - - if gate_op_order is not None: - version = ( - db.query(ProcedureVersion).filter(ProcedureVersion.id == instance.version_id).first() - ) - if version is not None: - version_step = next( - (s for s in version.content.get("steps", []) if s.get("order") == gate_op_order), - None, - ) - dep_orders = (version_step or {}).get("depends_on") or [] - if dep_orders: - terminal = { - StepStatus.COMPLETED.value, - StepStatus.SIGNED_OFF.value, - StepStatus.SKIPPED.value, - } - exec_lookup = {se.step_number: se for se in instance.step_executions} - blockers: list[str] = [] - for dep_order in dep_orders: - prereq = exec_lookup.get(dep_order) - if prereq is None: - continue - prereq_status = ( - prereq.status.value if hasattr(prereq.status, "value") else prereq.status - ) - if prereq_status not in terminal: - blockers.append(prereq.step_number_str or str(dep_order)) - if blockers: - raise HTTPException( - status_code=400, - detail="Cannot start: waiting on OP " + ", ".join(blockers), - ) - - # Redline gate: any incomplete ad-hoc op attached to the gate op must - # finish first. Orphans (NC soft-deleted) are ignored. - terminal_redline = { - StepStatus.COMPLETED.value, - StepStatus.SIGNED_OFF.value, - StepStatus.SKIPPED.value, - } - redline_blockers: list[str] = [] - redline_rows = ( - db.query(StepExecution) - .join(Issue, Issue.id == StepExecution.ad_hoc_issue_id) - .filter( - StepExecution.instance_id == instance.id, - StepExecution.ad_hoc_host_order == gate_op_order, - StepExecution.level == 0, - Issue.deleted_at.is_(None), - ) - .all() - ) - for r in redline_rows: - r_status = r.status.value if hasattr(r.status, "value") else r.status - if r_status not in terminal_redline: - redline_blockers.append(r.step_number_str or f"#{r.id}") - if redline_blockers: - raise HTTPException( - status_code=400, - detail="Cannot start: waiting on redline op " + ", ".join(redline_blockers), - ) - - step_exec.status = StepStatus.IN_PROGRESS - step_exec.started_at = datetime.now(UTC) - - # Get user name for event from opal.db.models import User user = db.query(User).filter(User.id == user_id).first() - user_name = user.name if user else None + if not user: + raise HTTPException(status_code=401, detail="Unknown user") + user.last_seen_at = datetime.now(UTC) + focus = focus_step(db, instance, step_exec, user) db.commit() - db.refresh(step_exec) - # Emit real-time events - await emit_step_started(instance_id, step_number, user_id, user_name) - if status_val == InstanceStatus.PENDING.value: - # Instance just started - await emit_instance_started(instance_id, instance.procedure_id, user_id, user_name) + await emit_cursor_moved(instance_id, data.step_number, user_id, user.name) - return StepExecutionResponse( - id=step_exec.id, - step_number=step_exec.step_number, - step_number_str=step_exec.step_number_str, - level=step_exec.level, - parent_step_order=step_exec.parent_step_order, - status=step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status, - data_captured=step_exec.data_captured, - started_at=step_exec.started_at, - completed_at=step_exec.completed_at, - completed_by_id=step_exec.completed_by_id, - signed_off_at=step_exec.signed_off_at, - notes=step_exec.notes, - signed_off_by_id=step_exec.signed_off_by_id, - duration_seconds=step_exec.duration_seconds, - ) + return { + "step_number": data.step_number, + "focused_at": focus.focused_at.isoformat(), + } + + +@router.get("/{instance_id}/state") +def get_execution_state( + instance_id: int, + db: DbSession, + user_id: CurrentUserId, +) -> dict: + """Full document state: steps, presence, holds — the controller's view. + + Polled by the execution document every 5s; identical payload to the MCP + get_execution_state tool. The poll doubles as the presence heartbeat. + """ + instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="Instance not found") + touch_user_presence(db, user_id) + return build_execution_state(db, instance) @router.post("/{instance_id}/steps/{step_number}/complete", response_model=StepExecutionResponse) @@ -723,138 +671,65 @@ async def complete_step( if not step_exec: raise HTTPException(status_code=404, detail="Step not found") - step_status = step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status - if step_status in [StepStatus.COMPLETED.value, StepStatus.SIGNED_OFF.value]: - raise HTTPException(status_code=400, detail="Step already completed") - - # Accept PENDING, IN_PROGRESS, or AWAITING_SIGNOFF (parent OPs whose children are done) - if step_status not in [ - StepStatus.PENDING.value, - StepStatus.IN_PROGRESS.value, - StepStatus.AWAITING_SIGNOFF.value, - ]: - raise HTTPException(status_code=400, detail=f"Cannot complete step in {step_status} status") - - # If step wasn't started, start it now - if step_status == StepStatus.PENDING.value: - step_exec.started_at = datetime.now(UTC) - - # Server-side data capture validation - version = db.query(ProcedureVersion).filter(ProcedureVersion.id == instance.version_id).first() - if data.data_captured and version: - step_data = next( - (s for s in version.content.get("steps", []) if s["order"] == step_number), {} + try: + result = complete_step_flow( + db, + instance, + step_exec, + user_id, + data_captured=data.data_captured, + notes=data.notes, ) - schema = step_data.get("required_data_schema") or {} - fields = schema.get("fields", []) - errors: list[str] = [] - for field in fields: - name = field.get("name") - val = data.data_captured.get(name) - if field.get("required") and (val is None or val == ""): - errors.append(f"{field.get('label', name)} is required") - if field.get("type") == "number" and val is not None and val != "": - try: - num_val = float(val) - except (TypeError, ValueError): - errors.append(f"{field.get('label', name)}: invalid number") - continue - if field.get("min") is not None and num_val < field["min"]: - errors.append( - f"{field.get('label', name)}: {num_val} below minimum {field['min']}" - ) - if field.get("max") is not None and num_val > field["max"]: - errors.append( - f"{field.get('label', name)}: {num_val} above maximum {field['max']}" - ) - if errors: - raise HTTPException(status_code=422, detail=errors) - - step_exec.status = StepStatus.COMPLETED - step_exec.completed_at = datetime.now(UTC) - step_exec.completed_by_id = user_id - if data.data_captured: - step_exec.data_captured = data.data_captured - if data.notes is not None: - step_exec.notes = data.notes + except FlowError as err: + raise HTTPException(status_code=err.status_code, detail=err.message) from err + if result.validation_errors: + raise HTTPException(status_code=422, detail=result.validation_errors) - # Get user name for event from opal.db.models import User user = db.query(User).filter(User.id == user_id).first() user_name = user.name if user else None - # Track old status to detect completion - old_instance_status = ( - instance.status.value if hasattr(instance.status, "value") else instance.status - ) - - # Check if procedure is complete (considering contingency rules) - _check_instance_completion(instance, db) - - # If this step is part of a redline op, re-evaluate the held host step's - # auto-resume — it may now be unblocked. - if step_exec.ad_hoc_issue_id is not None: - from opal.api.routes.issues import _maybe_resume_step_after_nc_update - - redline_issue = ( - db.query(Issue) - .filter(Issue.id == step_exec.ad_hoc_issue_id, Issue.deleted_at.is_(None)) - .first() - ) - if redline_issue is not None: - _maybe_resume_step_after_nc_update(db, redline_issue, user_id) - db.commit() db.refresh(step_exec) db.refresh(instance) # Emit real-time events await emit_step_completed(instance_id, step_number, user_id, user_name) + if result.instance_started: + await emit_instance_started(instance_id, instance.procedure_id, user_id, user_name) + if result.instance_completed: + await emit_instance_completed( + instance_id, instance.procedure_id, InstanceStatus.COMPLETED.value + ) - # Check if instance just completed - new_instance_status = ( - instance.status.value if hasattr(instance.status, "value") else instance.status - ) - if ( - old_instance_status != InstanceStatus.COMPLETED.value - and new_instance_status == InstanceStatus.COMPLETED.value - ): - await emit_instance_completed(instance_id, instance.procedure_id, new_instance_status) + return _step_response(step_exec) - return StepExecutionResponse( - id=step_exec.id, - step_number=step_exec.step_number, - step_number_str=step_exec.step_number_str, - level=step_exec.level, - parent_step_order=step_exec.parent_step_order, - status=step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status, - data_captured=step_exec.data_captured, - started_at=step_exec.started_at, - completed_at=step_exec.completed_at, - completed_by_id=step_exec.completed_by_id, - signed_off_at=step_exec.signed_off_at, - notes=step_exec.notes, - signed_off_by_id=step_exec.signed_off_by_id, - duration_seconds=step_exec.duration_seconds, - ) +class StepNoteCreate(BaseModel): + """Append one note to a step.""" -class StepNotesUpdate(BaseModel): - """Update step notes.""" + body: str - notes: str | None = None - -@router.patch("/{instance_id}/steps/{step_number}/notes", response_model=StepExecutionResponse) -def update_step_notes( +@router.post( + "/{instance_id}/steps/{step_number}/notes", + response_model=StepNoteResponse, + status_code=201, +) +def create_step_note( instance_id: int, step_number: int, - data: StepNotesUpdate, + data: StepNoteCreate, db: DbSession, user_id: CurrentUserId, -) -> StepExecutionResponse: - """Update notes on a step execution (while in progress or after completion).""" +) -> StepNoteResponse: + """Append a timestamped, authored note to a step. + + Notes are a record, not a control: any step status (pending, held, + terminal) and any instance status — a completed or aborted work order + still takes post-mortem notes. + """ instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() if not instance: raise HTTPException(status_code=404, detail="Instance not found") @@ -867,26 +742,14 @@ def update_step_notes( if not step_exec: raise HTTPException(status_code=404, detail="Step not found") - step_exec.notes = data.notes + try: + note = add_step_note(db, step_exec, data.body, user_id) + except FlowError as err: + raise HTTPException(status_code=err.status_code, detail=err.message) from err db.commit() - db.refresh(step_exec) + db.refresh(note) - return StepExecutionResponse( - id=step_exec.id, - step_number=step_exec.step_number, - step_number_str=step_exec.step_number_str, - level=step_exec.level, - parent_step_order=step_exec.parent_step_order, - status=step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status, - data_captured=step_exec.data_captured, - started_at=step_exec.started_at, - completed_at=step_exec.completed_at, - completed_by_id=step_exec.completed_by_id, - signed_off_at=step_exec.signed_off_at, - notes=step_exec.notes, - signed_off_by_id=step_exec.signed_off_by_id, - duration_seconds=step_exec.duration_seconds, - ) + return _note_response(note) class StepSkip(BaseModel): @@ -896,7 +759,7 @@ class StepSkip(BaseModel): @router.post("/{instance_id}/steps/{step_number}/skip", response_model=StepExecutionResponse) -def skip_step( +async def skip_step( instance_id: int, step_number: int, data: StepSkip, @@ -908,6 +771,10 @@ def skip_step( if not instance: raise HTTPException(status_code=404, detail="Instance not found") + inst_status = instance.status.value if hasattr(instance.status, "value") else instance.status + if inst_status not in (InstanceStatus.CUT.value, InstanceStatus.IN_WORK.value): + raise HTTPException(status_code=400, detail="Instance is not active") + step_exec = ( db.query(StepExecution) .filter(StepExecution.instance_id == instance_id, StepExecution.step_number == step_number) @@ -917,15 +784,23 @@ def skip_step( raise HTTPException(status_code=404, detail="Step not found") step_status = step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status - if step_status == StepStatus.COMPLETED.value: + if step_status in (StepStatus.COMPLETED.value, StepStatus.SIGNED_OFF.value): raise HTTPException(status_code=400, detail="Cannot skip completed step") - if step_status == StepStatus.ON_HOLD.value: + + # SKIP is a terminal commitment: it inherits the held scope (a hold cannot + # be skipped around) and any open redline-rework op on the gate — skipping + # the host step must not strand authorized rework (F5). It does not inherit + # strict_sequence/dependency ordering: skipping legitimately need not wait + # on predecessors. + holds = skip_blockers(db, instance, step_exec) + if holds: raise HTTPException( status_code=400, - detail="Cannot skip a step that is on hold for an open NC; " - "resolve the NC disposition first.", + detail="Cannot skip: " + "; ".join(b.message for b in holds), ) + instance_started = mark_instance_in_work(db, instance) + step_exec.status = StepStatus.SKIPPED step_exec.completed_at = datetime.now(UTC) step_exec.completed_by_id = user_id @@ -933,27 +808,15 @@ def skip_step( step_exec.data_captured = {"skip_reason": data.reason} # Check if procedure is complete (considering contingency rules) - _check_instance_completion(instance, db) + check_instance_completion(db, instance) db.commit() db.refresh(step_exec) - return StepExecutionResponse( - id=step_exec.id, - step_number=step_exec.step_number, - step_number_str=step_exec.step_number_str, - level=step_exec.level, - parent_step_order=step_exec.parent_step_order, - status=step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status, - data_captured=step_exec.data_captured, - started_at=step_exec.started_at, - completed_at=step_exec.completed_at, - completed_by_id=step_exec.completed_by_id, - signed_off_at=step_exec.signed_off_at, - notes=step_exec.notes, - signed_off_by_id=step_exec.signed_off_by_id, - duration_seconds=step_exec.duration_seconds, - ) + if instance_started: + await emit_instance_started(instance_id, instance.procedure_id, user_id, None) + + return _step_response(step_exec) @router.post("/{instance_id}/steps/{step_number}/signoff", response_model=StepExecutionResponse) @@ -973,6 +836,10 @@ async def signoff_step( if not instance: raise HTTPException(status_code=404, detail="Instance not found") + inst_status = instance.status.value if hasattr(instance.status, "value") else instance.status + if inst_status not in (InstanceStatus.CUT.value, InstanceStatus.IN_WORK.value): + raise HTTPException(status_code=400, detail="Instance is not active") + step_exec = ( db.query(StepExecution) .filter(StepExecution.instance_id == instance_id, StepExecution.step_number == step_number) @@ -996,6 +863,14 @@ async def signoff_step( status_code=400, detail=f"Cannot sign off step in {step_status} status" ) + # Containment gate: sign-off is a terminal commitment, same scope check + # as COMPLETE. + signoff_blockers = get_hold_state(db, instance_id).blockers_for_complete(step_exec) + if signoff_blockers: + raise HTTPException( + status_code=400, detail=_hold_blocker_detail("sign off", signoff_blockers) + ) + step_exec.status = StepStatus.SIGNED_OFF step_exec.signed_off_at = datetime.now(UTC) step_exec.signed_off_by_id = user_id @@ -1006,20 +881,7 @@ async def signoff_step( ) # Check if procedure is complete - _check_instance_completion(instance, db) - - # If this signoff terminates a redline step, re-evaluate the held host - # step's auto-resume — it may now be unblocked. - if step_exec.ad_hoc_issue_id is not None: - from opal.api.routes.issues import _maybe_resume_step_after_nc_update - - redline_issue = ( - db.query(Issue) - .filter(Issue.id == step_exec.ad_hoc_issue_id, Issue.deleted_at.is_(None)) - .first() - ) - if redline_issue is not None: - _maybe_resume_step_after_nc_update(db, redline_issue, user_id) + check_instance_completion(db, instance) db.commit() db.refresh(step_exec) @@ -1035,102 +897,7 @@ async def signoff_step( ): await emit_instance_completed(instance_id, instance.procedure_id, new_instance_status) - return StepExecutionResponse( - id=step_exec.id, - step_number=step_exec.step_number, - step_number_str=step_exec.step_number_str, - level=step_exec.level, - parent_step_order=step_exec.parent_step_order, - status=step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status, - data_captured=step_exec.data_captured, - started_at=step_exec.started_at, - completed_at=step_exec.completed_at, - completed_by_id=step_exec.completed_by_id, - signed_off_at=step_exec.signed_off_at, - notes=step_exec.notes, - signed_off_by_id=step_exec.signed_off_by_id, - duration_seconds=step_exec.duration_seconds, - ) - - -def _check_instance_completion(instance: ProcedureInstance, db: DbSession) -> None: - """Check if instance should be marked as completed. - - Rules: - - All non-contingency steps must be completed, signed_off, or skipped - - Parent steps auto-complete to COMPLETED when all children are done - - All step types accept COMPLETED, SIGNED_OFF, or SKIPPED as done - - Contingency steps are optional (only required if explicitly started) - """ - version = db.query(ProcedureVersion).filter(ProcedureVersion.id == instance.version_id).first() - if not version: - return - - version_steps = {s["order"]: s for s in version.content.get("steps", [])} - all_steps = db.query(StepExecution).filter(StepExecution.instance_id == instance.id).all() - - # First pass: check if any parent steps should auto-complete when all children are done - for step_exec in all_steps: - if step_exec.level == 0: # This is a parent OP - # Find all children of this parent - children = [s for s in all_steps if s.parent_step_order == step_exec.step_number] - - if children: # Has sub-steps - step_status = ( - step_exec.status.value - if hasattr(step_exec.status, "value") - else step_exec.status - ) - - # If parent is still PENDING or IN_PROGRESS, check if all children are done - if step_status in [StepStatus.PENDING.value, StepStatus.IN_PROGRESS.value]: - all_children_done = all( - (c.status.value if hasattr(c.status, "value") else c.status) - in [StepStatus.COMPLETED.value, StepStatus.SKIPPED.value] - for c in children - ) - if all_children_done: - step_exec.status = StepStatus.COMPLETED - step_exec.completed_at = datetime.now(UTC) - - # Use the last child's completer - def _to_aware(dt: datetime) -> datetime: - return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt - - last_child = max( - (c for c in children if c.completed_at), - key=lambda c: _to_aware(c.completed_at), - default=None, - ) - if last_child and last_child.completed_by_id: - step_exec.completed_by_id = last_child.completed_by_id - - # Second pass: check instance completion - for step_exec in all_steps: - step_data = version_steps.get(step_exec.step_number, {}) - is_contingency = step_data.get("is_contingency", False) - step_status = ( - step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status - ) - - # All step types accept COMPLETED, SIGNED_OFF, or SKIPPED as done - done_statuses = [ - StepStatus.COMPLETED.value, - StepStatus.SIGNED_OFF.value, - StepStatus.SKIPPED.value, - ] - - # Non-contingency steps must be done - if not is_contingency and step_status not in done_statuses: - return # Not complete yet - - # Contingency steps that were started must be completed - if is_contingency and step_status == StepStatus.IN_PROGRESS.value: - return # In-progress contingency blocks completion - - # All required steps are done - instance.status = InstanceStatus.COMPLETED - instance.completed_at = datetime.now(UTC) + return _step_response(step_exec) @router.post("/{instance_id}/steps/{step_number}/nc", status_code=201) @@ -1141,7 +908,11 @@ def log_non_conformance( db: DbSession, user_id: CurrentUserId, ) -> dict: - """Log a non-conformance during step execution, creates an Issue.""" + """Capture a non-conformance during step execution, creates an Issue. + + The hold is the issue itself: an undispositioned issue with step + containment makes the step's COMPLETE absent. Step status is not touched. + """ instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() if not instance: raise HTTPException(status_code=404, detail="Instance not found") @@ -1154,12 +925,37 @@ def log_non_conformance( if not step_exec: raise HTTPException(status_code=404, detail="Step not found") - # Create issue try: priority = IssuePriority(data.priority) except ValueError: priority = IssuePriority.MEDIUM + try: + containment = Containment(data.containment) + except ValueError as err: + raise HTTPException( + status_code=400, detail=f"Invalid containment: {data.containment}" + ) from err + + # Optional boundary ("resolve by"): a step-contained hold may bind to a + # later step; None anchors at the raised step. + containment_step_id = None + if data.containment_step_number is not None: + boundary = ( + db.query(StepExecution) + .filter( + StepExecution.instance_id == instance_id, + StepExecution.step_number == data.containment_step_number, + ) + .first() + ) + if boundary is None: + raise HTTPException( + status_code=404, + detail=f"Boundary step {data.containment_step_number} not found", + ) + containment_step_id = boundary.id + issue = Issue( issue_number=generate_issue_number(db), title=data.title, @@ -1167,64 +963,53 @@ def log_non_conformance( issue_type=IssueType.NON_CONFORMANCE, status=IssueStatus.OPEN, priority=priority, + containment=containment, + containment_step_id=containment_step_id, + should_be=data.should_be, + actual=data.actual, procedure_id=instance.procedure_id, procedure_instance_id=instance_id, - step_execution_id=step_exec.id, + raised_step_id=step_exec.id, + raised_by_id=user_id, + assigned_to_id=data.assigned_to_id, ) db.add(issue) db.flush() log_create(db, issue, user_id) - - # Put the step on hold until the NC disposition is approved or the issue closed. - step_status_now = ( - step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status - ) - if step_status_now != StepStatus.ON_HOLD.value: - step_old = get_model_dict(step_exec) - step_exec.status = StepStatus.ON_HOLD - log_update(db, step_exec, step_old, user_id) - - # Propagate hold to the parent op so the whole operation stalls — not - # just the offending sub-step. Other sub-steps in the same op must not - # be startable while an NC is open anywhere inside it. - if step_exec.level > 0 and step_exec.parent_step_order is not None: - parent_exec = ( - db.query(StepExecution) - .filter( - StepExecution.instance_id == instance_id, - StepExecution.step_number == step_exec.parent_step_order, - StepExecution.level == 0, - ) - .first() - ) - if parent_exec is not None: - parent_status = ( - parent_exec.status.value - if hasattr(parent_exec.status, "value") - else parent_exec.status - ) - if parent_status != StepStatus.ON_HOLD.value: - parent_old = get_model_dict(parent_exec) - parent_exec.status = StepStatus.ON_HOLD - log_update(db, parent_exec, parent_old, user_id) - db.commit() db.refresh(issue) return { "id": issue.id, + "issue_number": issue.issue_number, "title": issue.title, "issue_type": issue.issue_type.value if hasattr(issue.issue_type, "value") else issue.issue_type, "status": issue.status.value if hasattr(issue.status, "value") else issue.status, "priority": issue.priority.value if hasattr(issue.priority, "value") else issue.priority, + "containment": issue.containment.value + if hasattr(issue.containment, "value") + else issue.containment, "procedure_instance_id": instance_id, "step_number": step_number, } +@router.get("/{instance_id}/holds") +def get_instance_holds( + instance_id: int, + db: DbSession, +) -> dict: + """The controller's blocking state: every undispositioned issue holding + this work order and what it blocks.""" + instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="Instance not found") + return get_holds_payload(db, instance_id) + + @router.get("/{instance_id}/version-content") def get_instance_version_content( instance_id: int, @@ -1250,6 +1035,7 @@ class KitAvailabilityItem(BaseModel): part_id: int part_name: str + uom: str | None = None quantity_required: float quantity_available: float is_available: bool @@ -1327,12 +1113,14 @@ def check_kit_availability( KitAvailabilityItem( part_id=kit_item.part_id, part_name=kit_item.part.name, + uom=kit_item.part.unit_of_measure, quantity_required=qty_required, quantity_available=total_available, is_available=is_available, available_locations=[ { "inventory_record_id": r.id, + "opal_number": r.opal_number, "location": r.location, "lot_number": r.lot_number, "quantity": float(r.quantity), @@ -2081,8 +1869,9 @@ async def leave_execution( leaving_user = next((p for p in participants if p.get("user_id") == user_id), None) user_name = leaving_user.get("user_name", "Unknown") if leaving_user else "Unknown" - # Remove user from participants + # Remove user from participants and drop their cursor instance.participants = [p for p in participants if p.get("user_id") != user_id] + clear_focus(db, instance_id, user_id) db.commit() # Emit user left event @@ -2180,25 +1969,7 @@ def _redline_op_response(db, op_row: StepExecution) -> AdHocOpResponse: title=op_row.title or "", status=op_row.status.value if hasattr(op_row.status, "value") else op_row.status, created_at=op_row.created_at.isoformat() if op_row.created_at else "", - sub_steps=[ - StepExecutionResponse( - id=s.id, - step_number=s.step_number, - step_number_str=s.step_number_str, - level=s.level, - parent_step_order=s.parent_step_order, - status=s.status.value if hasattr(s.status, "value") else s.status, - data_captured=s.data_captured, - started_at=s.started_at, - completed_at=s.completed_at, - completed_by_id=s.completed_by_id, - notes=s.notes, - signed_off_at=s.signed_off_at, - signed_off_by_id=s.signed_off_by_id, - duration_seconds=s.duration_seconds, - ) - for s in sub_rows - ], + sub_steps=[_step_response(s) for s in sub_rows], ) @@ -2226,12 +1997,12 @@ def create_ad_hoc_op( issue_type = issue.issue_type.value if hasattr(issue.issue_type, "value") else issue.issue_type if issue_type != IssueType.NON_CONFORMANCE.value: raise HTTPException(status_code=400, detail="Redlines can only be attached to an NC") - if issue.step_execution_id is None: + if issue.raised_step_id is None: raise HTTPException( status_code=400, detail="NC is not attached to a step; cannot create redline" ) - host_exec = db.get(StepExecution, issue.step_execution_id) + host_exec = db.get(StepExecution, issue.raised_step_id) if not host_exec or host_exec.instance_id != instance.id: raise HTTPException(status_code=400, detail="NC does not belong to this execution") diff --git a/src/opal/api/routes/inventory.py b/src/opal/api/routes/inventory.py index ddeda1c..6e2b1d2 100644 --- a/src/opal/api/routes/inventory.py +++ b/src/opal/api/routes/inventory.py @@ -78,6 +78,7 @@ class InventoryResponse(BaseModel): part_id: int part_name: str part_external_pn: str | None + part_uom: str | None = None quantity: Decimal location: str lot_number: str | None @@ -149,6 +150,7 @@ def inventory_to_response(record: InventoryRecord) -> InventoryResponse: part_id=record.part_id, part_name=record.part.name, part_external_pn=record.part.external_pn, + part_uom=record.part.unit_of_measure, quantity=record.quantity, location=record.location, lot_number=record.lot_number, diff --git a/src/opal/api/routes/issues.py b/src/opal/api/routes/issues.py index df97718..5b934e0 100644 --- a/src/opal/api/routes/issues.py +++ b/src/opal/api/routes/issues.py @@ -1,4 +1,10 @@ -"""Issues API routes.""" +"""Issues API routes. + +Two gates, not one: raised → UNDISPOSITIONED → DISPOSITIONED → CLOSED. +The blocking predicate is `undispositioned`, never `open`. Signing a +disposition (POST /{id}/disposition) releases every containment the issue +holds, live; closure is bookkeeping that follows at its own pace. +""" from datetime import UTC, datetime @@ -8,8 +14,12 @@ from opal.api.deps import CurrentUserId, DbSession from opal.core.audit import get_model_dict, log_create, log_delete, log_update from opal.core.designators import generate_issue_number -from opal.db.models.execution import StepExecution, StepStatus +from opal.core.events import emit_issue_dispositioned +from opal.core.holds import holding_readout +from opal.db.models.execution import StepExecution from opal.db.models.issue import ( + CONTAINMENT_RANK, + Containment, DispositionType, Issue, IssuePriority, @@ -27,6 +37,39 @@ def _get_enum_val(obj: object, attr: str) -> str: return val.value if hasattr(val, "value") else val +def _resolve_containment_instance( + db, + containment: Containment, + containment_step_id: int | None, + raised_step_id: int | None, + procedure_instance_id: int | None, +) -> int | None: + """Reconcile a non-advisory issue's work order against its step anchors. + + Derives procedure_instance_id from a step anchor when unset, and rejects a + procedure_instance_id (or a second anchor) that names a different work order + (F3). Advisory issues pass through untouched — they hold nothing. + """ + if containment == Containment.ADVISORY: + return procedure_instance_id + resolved = procedure_instance_id + for step_id in (containment_step_id, raised_step_id): + if step_id is None: + continue + step = db.query(StepExecution).filter(StepExecution.id == step_id).first() + if step is None: + raise HTTPException(status_code=400, detail=f"Step {step_id} not found") + if resolved is None: + resolved = step.instance_id + elif step.instance_id != resolved: + raise HTTPException( + status_code=400, + detail=f"Step {step_id} belongs to a different work order " + "than procedure_instance_id", + ) + return resolved + + # ============ Schemas ============ @@ -40,12 +83,17 @@ class IssueResponse(BaseModel): issue_type: str status: str priority: str + containment: str + containment_step_id: int | None = None + disp_state: str + dispositioned: bool part_id: int | None = None procedure_id: int | None = None procedure_instance_id: int | None = None - step_execution_id: int | None = None + raised_step_id: int | None = None + raised_by_id: int | None = None should_be: str | None = None - is_condition: str | None = None + actual: str | None = None steps_to_reproduce: str | None = None expected_behavior: str | None = None actual_behavior: str | None = None @@ -53,9 +101,10 @@ class IssueResponse(BaseModel): root_cause: str | None = None corrective_action: str | None = None disposition_type: str | None = None - disposition_notes: str | None = None + disposition_rationale: str | None = None assigned_to_id: int | None = None - disposition_approved_by_id: int | None = None + dispositioned_by_id: int | None = None + dispositioned_at: datetime | None = None created_at: datetime updated_at: datetime @@ -78,8 +127,10 @@ class IssueCreate(BaseModel): description: str | None = None issue_type: str = "task" priority: str = "medium" + containment: str = "advisory" + containment_step_id: int | None = None should_be: str | None = None - is_condition: str | None = None + actual: str | None = None steps_to_reproduce: str | None = None expected_behavior: str | None = None actual_behavior: str | None = None @@ -87,11 +138,13 @@ class IssueCreate(BaseModel): part_id: int | None = None procedure_id: int | None = None procedure_instance_id: int | None = None + raised_step_id: int | None = None assigned_to_id: int | None = None class IssueUpdate(BaseModel): - """Update issue request.""" + """Update issue request. Disposition is signed via POST /{id}/disposition, + not patched; containment changes go through POST /{id}/containment.""" title: str | None = Field(None, min_length=1, max_length=255) description: str | None = None @@ -99,7 +152,7 @@ class IssueUpdate(BaseModel): status: str | None = None priority: str | None = None should_be: str | None = None - is_condition: str | None = None + actual: str | None = None steps_to_reproduce: str | None = None expected_behavior: str | None = None actual_behavior: str | None = None @@ -110,9 +163,24 @@ class IssueUpdate(BaseModel): root_cause: str | None = None corrective_action: str | None = None disposition_type: str | None = None - disposition_notes: str | None = None + disposition_rationale: str | None = None assigned_to_id: int | None = None - disposition_approved_by_id: int | None = None + + +class DispositionSign(BaseModel): + """Sign a disposition — the signature moment.""" + + disposition_type: str + disposition_rationale: str = Field(..., min_length=1) + + +class ContainmentChange(BaseModel): + """Change containment scope. Narrowing releases a hold and is audited + with a note.""" + + containment: str + containment_step_id: int | None = None + note: str | None = None class IssueCommentResponse(BaseModel): @@ -143,12 +211,17 @@ def _issue_to_response(issue: Issue) -> IssueResponse: issue_type=_get_enum_val(issue, "issue_type"), status=_get_enum_val(issue, "status"), priority=_get_enum_val(issue, "priority"), + containment=_get_enum_val(issue, "containment"), + containment_step_id=issue.containment_step_id, + disp_state=issue.disp_state, + dispositioned=issue.dispositioned, part_id=issue.part_id, procedure_id=issue.procedure_id, procedure_instance_id=issue.procedure_instance_id, - step_execution_id=issue.step_execution_id, + raised_step_id=issue.raised_step_id, + raised_by_id=issue.raised_by_id, should_be=issue.should_be, - is_condition=issue.is_condition, + actual=issue.actual, steps_to_reproduce=issue.steps_to_reproduce, expected_behavior=issue.expected_behavior, actual_behavior=issue.actual_behavior, @@ -158,9 +231,10 @@ def _issue_to_response(issue: Issue) -> IssueResponse: disposition_type=_get_enum_val(issue, "disposition_type") if issue.disposition_type else None, - disposition_notes=issue.disposition_notes, + disposition_rationale=issue.disposition_rationale, assigned_to_id=issue.assigned_to_id, - disposition_approved_by_id=issue.disposition_approved_by_id, + dispositioned_by_id=issue.dispositioned_by_id, + dispositioned_at=issue.dispositioned_at, created_at=issue.created_at, updated_at=issue.updated_at, ) @@ -193,6 +267,12 @@ def get_disposition_types() -> list[str]: return [d.value for d in DispositionType] +@router.get("/containments", response_model=list[str]) +def get_containments() -> list[str]: + """Get all containment scopes.""" + return [c.value for c in Containment] + + # ============ Issue CRUD ============ @@ -202,6 +282,7 @@ def list_issues( search: str | None = Query(None), issue_type: str | None = Query(None), status: str | None = Query(None), + disp_state: str | None = Query(None), priority: str | None = Query(None), part_id: int | None = Query(None), procedure_id: int | None = Query(None), @@ -220,6 +301,8 @@ def list_issues( query = query.filter(Issue.issue_type == issue_type) if status: query = query.filter(Issue.status == status) + if disp_state: + query = _filter_disp_state(query, disp_state) if priority: query = query.filter(Issue.priority == priority) if part_id: @@ -241,6 +324,31 @@ def list_issues( ) +def _filter_disp_state(query, disp_state: str): + """The four-value STATE filter, matching what the list column renders: + open = advisory-open; undispositioned / dispositioned = containment-bearing + only; closed = closed.""" + from sqlalchemy import and_, or_ + + signed = and_(Issue.disposition_type.isnot(None), Issue.dispositioned_at.isnot(None)) + bearing = Issue.containment != Containment.ADVISORY + if disp_state == "closed": + return query.filter(Issue.status == IssueStatus.CLOSED) + if disp_state == "open": + return query.filter( + Issue.status != IssueStatus.CLOSED, Issue.containment == Containment.ADVISORY + ) + if disp_state == "dispositioned": + return query.filter(Issue.status != IssueStatus.CLOSED, bearing, signed) + if disp_state == "undispositioned": + return query.filter( + Issue.status != IssueStatus.CLOSED, + bearing, + or_(Issue.disposition_type.is_(None), Issue.dispositioned_at.is_(None)), + ) + raise HTTPException(status_code=400, detail=f"Invalid disp_state: {disp_state}") + + @router.post("", response_model=IssueResponse, status_code=201) def create_issue( data: IssueCreate, @@ -261,6 +369,22 @@ def create_issue( except ValueError as err: raise HTTPException(status_code=400, detail=f"Invalid priority: {data.priority}") from err + try: + containment = Containment(data.containment) + except ValueError as err: + raise HTTPException( + status_code=400, detail=f"Invalid containment: {data.containment}" + ) from err + + # A non-advisory containment holds a specific work order via the anchor's + # procedure_instance_id (core/holds filters on it). Derive it from any step + # anchor and reject anchors that name a different work order — otherwise the + # hold reports success but blocks nothing, or blocks the wrong WO (F3). A + # bare draft (no anchor, no WO) is still allowed; the WO is linked later. + procedure_instance_id = _resolve_containment_instance( + db, containment, data.containment_step_id, data.raised_step_id, data.procedure_instance_id + ) + issue = Issue( issue_number=generate_issue_number(db), title=data.title, @@ -268,15 +392,19 @@ def create_issue( issue_type=issue_type, status=IssueStatus.OPEN, priority=priority, + containment=containment, + containment_step_id=data.containment_step_id, should_be=data.should_be, - is_condition=data.is_condition, + actual=data.actual, steps_to_reproduce=data.steps_to_reproduce, expected_behavior=data.expected_behavior, actual_behavior=data.actual_behavior, expected_benefit=data.expected_benefit, part_id=data.part_id, procedure_id=data.procedure_id, - procedure_instance_id=data.procedure_instance_id, + procedure_instance_id=procedure_instance_id, + raised_step_id=data.raised_step_id, + raised_by_id=user_id, assigned_to_id=data.assigned_to_id, ) db.add(issue) @@ -302,6 +430,21 @@ def get_issue( return _issue_to_response(issue) +@router.get("/{issue_id}/holding", response_model=list[dict]) +def get_issue_holding( + issue_id: int, + db: DbSession, +) -> list[dict]: + """What this issue is stopping: [{label, scope, href}], empty for advisory + or dispositioned issues. The disposition confirm dialog reads its + consequence sentence from here.""" + issue = db.query(Issue).filter(Issue.id == issue_id, Issue.deleted_at.is_(None)).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + + return holding_readout(db, issue) + + @router.patch("/{issue_id}", response_model=IssueResponse) def update_issue( issue_id: int, @@ -332,16 +475,8 @@ def update_issue( new_status = IssueStatus(data.status) except ValueError as err: raise HTTPException(status_code=400, detail=f"Invalid status: {data.status}") from err - # Disposition approval requires disposition_type to be set - if new_status == IssueStatus.DISPOSITION_APPROVED: - effective_disposition_type = data.disposition_type or ( - _get_enum_val(issue, "disposition_type") if issue.disposition_type else None - ) - if not effective_disposition_type: - raise HTTPException( - status_code=400, - detail="disposition_type is required when approving disposition", - ) + if new_status == IssueStatus.CLOSED: + _validate_close(issue, data) issue.status = new_status if data.priority is not None: try: @@ -352,8 +487,8 @@ def update_issue( ) from err if data.should_be is not None: issue.should_be = data.should_be - if data.is_condition is not None: - issue.is_condition = data.is_condition + if data.actual is not None: + issue.actual = data.actual if data.steps_to_reproduce is not None: issue.steps_to_reproduce = data.steps_to_reproduce if data.expected_behavior is not None: @@ -372,146 +507,195 @@ def update_issue( issue.root_cause = data.root_cause if data.corrective_action is not None: issue.corrective_action = data.corrective_action - if data.disposition_type is not None: - if data.disposition_type == "": - # Clearing disposition_type — block if status is disposition_approved - current_status = _get_enum_val(issue, "status") if issue.status else None - if current_status == "disposition_approved": - raise HTTPException( - status_code=400, - detail="Cannot clear disposition_type while status is disposition_approved", - ) - issue.disposition_type = None - else: - try: - issue.disposition_type = DispositionType(data.disposition_type) - except ValueError as err: - raise HTTPException( - status_code=400, detail=f"Invalid disposition type: {data.disposition_type}" - ) from err - if data.disposition_notes is not None: - issue.disposition_notes = data.disposition_notes + if data.disposition_type is not None or data.disposition_rationale is not None: + # Drafting the disposition panel is a PATCH; the signature is not. + # A signed disposition is immutable — a different decision is a new + # signature, not an edit. + if issue.dispositioned: + raise HTTPException( + status_code=400, + detail="Disposition already signed; it cannot be edited", + ) + if data.disposition_type is not None: + if data.disposition_type == "": + issue.disposition_type = None + else: + try: + issue.disposition_type = DispositionType(data.disposition_type) + except ValueError as err: + raise HTTPException( + status_code=400, + detail=f"Invalid disposition type: {data.disposition_type}", + ) from err + if data.disposition_rationale is not None: + issue.disposition_rationale = data.disposition_rationale if data.assigned_to_id is not None: issue.assigned_to_id = data.assigned_to_id - if data.disposition_approved_by_id is not None: - issue.disposition_approved_by_id = data.disposition_approved_by_id log_update(db, issue, old_values, user_id) - # Auto-resume step on hold once all linked NCs reach a terminal disposition. - _maybe_resume_step_after_nc_update(db, issue, user_id) - db.commit() db.refresh(issue) return _issue_to_response(issue) -def _maybe_resume_step_after_nc_update(db, issue: "Issue", user_id: int | None) -> None: - """If this NC just reached a terminal state and no other open NCs remain on - its step, pop the step back to IN_PROGRESS.""" - if issue.step_execution_id is None: - return - issue_type = issue.issue_type.value if hasattr(issue.issue_type, "value") else issue.issue_type - if issue_type != IssueType.NON_CONFORMANCE.value: - return - issue_status = issue.status.value if hasattr(issue.status, "value") else issue.status - if issue_status not in (IssueStatus.DISPOSITION_APPROVED.value, IssueStatus.CLOSED.value): +def _recheck_instance_completion(db, issue: Issue) -> None: + """Releasing a hold may have been the last thing standing between a work + order and completion (e.g. wo/op containment signed after every step + finished) — re-evaluate.""" + if issue.procedure_instance_id is None: return + from opal.core.execution_flow import check_instance_completion + from opal.db.models.execution import ProcedureInstance + + instance = db.get(ProcedureInstance, issue.procedure_instance_id) + if instance is not None: + check_instance_completion(db, instance) - step_exec = db.get(StepExecution, issue.step_execution_id) - if step_exec is None: - return - step_status = step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status - if step_status != StepStatus.ON_HOLD.value: - return - remaining = ( - db.query(Issue) - .filter( - Issue.step_execution_id == step_exec.id, - Issue.issue_type == IssueType.NON_CONFORMANCE, - Issue.id != issue.id, - Issue.status.notin_([IssueStatus.DISPOSITION_APPROVED, IssueStatus.CLOSED]), - Issue.deleted_at.is_(None), +def _validate_close(issue: Issue, data: IssueUpdate | None = None) -> None: + """Closing an undispositioned containment-bearing issue is impossible — + disposition first, always. Advisory issues close freely. NC-type issues + additionally require corrective action text (type-based, any containment).""" + if issue.containment_bearing and not issue.dispositioned: + raise HTTPException( + status_code=400, + detail=f"{issue.issue_number} is undispositioned; sign a disposition before closing", ) - .count() - ) - if remaining != 0: - return + issue_type = _get_enum_val(issue, "issue_type") + if issue_type == IssueType.NON_CONFORMANCE.value: + corrective = issue.corrective_action + if data is not None and data.corrective_action is not None: + corrective = data.corrective_action + if not (corrective or "").strip(): + raise HTTPException( + status_code=400, + detail=f"{issue.issue_number} is a non-conformance; " + "corrective action is required to close", + ) - # Hold back if any redline op authorized by an NC on this step is still - # outstanding. Approving the disposition isn't enough — the rework itself - # has to be completed before the held step can resume. - terminal_states = ( - StepStatus.COMPLETED, - StepStatus.SIGNED_OFF, - StepStatus.SKIPPED, - ) - open_redlines = ( - db.query(StepExecution) - .join(Issue, Issue.id == StepExecution.ad_hoc_issue_id) - .filter( - StepExecution.instance_id == step_exec.instance_id, - StepExecution.level == 0, - Issue.step_execution_id == step_exec.id, - Issue.deleted_at.is_(None), - StepExecution.status.notin_(terminal_states), + +@router.post("/{issue_id}/disposition", response_model=IssueResponse) +async def sign_disposition( + issue_id: int, + data: DispositionSign, + db: DbSession, + user_id: CurrentUserId, +) -> IssueResponse: + """Sign the disposition — sets the signature and releases every + containment this issue holds, live.""" + issue = db.query(Issue).filter(Issue.id == issue_id, Issue.deleted_at.is_(None)).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + + if _get_enum_val(issue, "status") == IssueStatus.CLOSED.value: + raise HTTPException(status_code=400, detail="Issue is closed") + if issue.dispositioned: + raise HTTPException(status_code=400, detail="Disposition already signed") + if not issue.containment_bearing: + raise HTTPException( + status_code=400, + detail=f"{issue.issue_number} is advisory; there is no disposition to sign", ) - .count() - ) - if open_redlines: - return - step_old = get_model_dict(step_exec) - step_exec.status = StepStatus.IN_PROGRESS - log_update(db, step_exec, step_old, user_id) - - # Propagate resume up: when an NC was logged on a sub-step we also flipped - # the parent op to ON_HOLD so the whole operation paused. Now that this - # sub-step is clear, resume the parent too — but only if no other - # sub-step of that parent has any open NC remaining. - if step_exec.level > 0 and step_exec.parent_step_order is not None: - parent_exec = ( - db.query(StepExecution) - .filter( - StepExecution.instance_id == step_exec.instance_id, - StepExecution.step_number == step_exec.parent_step_order, - StepExecution.level == 0, - ) - .first() + try: + disposition_type = DispositionType(data.disposition_type) + except ValueError as err: + raise HTTPException( + status_code=400, detail=f"Invalid disposition type: {data.disposition_type}" + ) from err + + old_values = get_model_dict(issue) + issue.disposition_type = disposition_type + issue.disposition_rationale = data.disposition_rationale + issue.dispositioned_by_id = user_id + issue.dispositioned_at = datetime.now(UTC) + + log_update(db, issue, old_values, user_id) + db.flush() + _recheck_instance_completion(db, issue) + db.commit() + db.refresh(issue) + + # Held rows recover their controls without reload. + if issue.procedure_instance_id is not None: + await emit_issue_dispositioned(issue.procedure_instance_id, issue.id, issue.issue_number) + + return _issue_to_response(issue) + + +@router.post("/{issue_id}/containment", response_model=IssueResponse) +def set_containment( + issue_id: int, + data: ContainmentChange, + db: DbSession, + user_id: CurrentUserId, +) -> IssueResponse: + """Change containment scope. Widening is one click; narrowing (or + downgrading to advisory) releases a hold — a decision, not an edit — and + is audited with a note.""" + issue = db.query(Issue).filter(Issue.id == issue_id, Issue.deleted_at.is_(None)).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + + try: + new_containment = Containment(data.containment) + except ValueError as err: + raise HTTPException( + status_code=400, detail=f"Invalid containment: {data.containment}" + ) from err + + old_containment = _get_enum_val(issue, "containment") + narrowing = CONTAINMENT_RANK[new_containment.value] < CONTAINMENT_RANK[old_containment] + if narrowing and issue.is_blocking and not (data.note or "").strip(): + raise HTTPException( + status_code=400, + detail=f"Narrowing containment {old_containment} → {new_containment.value} " + "releases a hold; a note is required", ) - if parent_exec is not None: - parent_status = ( - parent_exec.status.value - if hasattr(parent_exec.status, "value") - else parent_exec.status - ) - if parent_status == StepStatus.ON_HOLD.value: - sibling_se_ids = [ - se.id - for se in db.query(StepExecution) - .filter( - StepExecution.instance_id == step_exec.instance_id, - StepExecution.parent_step_order == parent_exec.step_number, - ) - .all() - ] - sibling_se_ids.append(parent_exec.id) - siblings_open = ( - db.query(Issue) - .filter( - Issue.step_execution_id.in_(sibling_se_ids), - Issue.issue_type == IssueType.NON_CONFORMANCE, - Issue.status.notin_([IssueStatus.DISPOSITION_APPROVED, IssueStatus.CLOSED]), - Issue.deleted_at.is_(None), - ) - .count() + + old_values = get_model_dict(issue) + issue.containment = new_containment + # Boundary re-binding uses explicit presence, not None-skip: an omitted + # containment_step_id leaves the boundary untouched, while an explicit null + # clears it (a mis-bound boundary was previously immutable — F8). A non-null + # boundary must belong to this issue's work order; it seeds the WO on a + # still-unlinked issue and is rejected when it names a different one. + if "containment_step_id" in data.model_fields_set: + new_step_id = data.containment_step_id + if new_step_id is not None: + step = db.query(StepExecution).filter(StepExecution.id == new_step_id).first() + if step is None: + raise HTTPException(status_code=404, detail=f"Step {new_step_id} not found") + if issue.procedure_instance_id is None: + issue.procedure_instance_id = step.instance_id + elif step.instance_id != issue.procedure_instance_id: + raise HTTPException( + status_code=400, + detail=f"Step {new_step_id} belongs to a different work order than this issue", ) - if siblings_open == 0: - parent_old = get_model_dict(parent_exec) - parent_exec.status = StepStatus.IN_PROGRESS - log_update(db, parent_exec, parent_old, user_id) + issue.containment_step_id = new_step_id + log_update(db, issue, old_values, user_id) + + if narrowing and (data.note or "").strip(): + comment = IssueComment( + issue_id=issue.id, + user_id=user_id, + body=f"Containment narrowed {old_containment} → {new_containment.value}: {data.note}", + ) + db.add(comment) + db.flush() + log_create(db, comment, user_id) + + if narrowing: + db.flush() + _recheck_instance_completion(db, issue) + + db.commit() + db.refresh(issue) + + return _issue_to_response(issue) @router.delete("/{issue_id}", status_code=204) @@ -527,6 +711,8 @@ def delete_issue( issue.deleted_at = datetime.now(UTC) log_delete(db, issue, user_id) + db.flush() + _recheck_instance_completion(db, issue) db.commit() diff --git a/src/opal/api/routes/parts.py b/src/opal/api/routes/parts.py index 9bd8b4b..89fda60 100644 --- a/src/opal/api/routes/parts.py +++ b/src/opal/api/routes/parts.py @@ -4,7 +4,7 @@ import io import re from decimal import Decimal -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, status from fastapi.responses import Response @@ -48,6 +48,9 @@ class PartCreate(BaseModel): # "bulk" = one OPAL per batch, "serialized" = one OPAL per unit; # None resolves by tier convention (see default_tracking_for_tier) tracking_type: str | None = None + # Declares which sections expect content (make: BOM; buy: suppliers/POs); + # None resolves by heuristic: buy when an external PN is given, else make + procurement: Literal["make", "buy", "both"] | None = None tier: int = 1 # Tier level; must be one of the project's configured tiers parent_id: int | None = None # Parent assembly if this is a child part reorder_point: Decimal | None = None @@ -66,6 +69,7 @@ class PartUpdate(BaseModel): category: str | None = None unit_of_measure: str | None = None tracking_type: str | None = None # "bulk" or "serialized" + procurement: Literal["make", "buy", "both"] | None = None tier: int | None = None parent_id: int | None = None reorder_point: Decimal | None = None @@ -94,6 +98,7 @@ class PartResponse(BaseModel): category: str | None unit_of_measure: str tracking_type: str # "bulk" or "serialized" + procurement: str # "make", "buy", or "both" tier: int tier_name: str | None = None # Populated from project config if available parent_id: int | None @@ -149,6 +154,7 @@ def get_part_with_quantity(db: DbSession, part: Part) -> PartResponse: category=part.category, unit_of_measure=part.unit_of_measure, tracking_type=part.tracking_type, + procurement=part.procurement, tier=part.tier, tier_name=tier_name, parent_id=part.parent_id, @@ -317,6 +323,7 @@ def create_part( category=part_in.category, unit_of_measure=part_in.unit_of_measure, tracking_type=part_in.tracking_type or default_tracking_for_tier(part_in.tier), + procurement=part_in.procurement or ("buy" if part_in.external_pn else "make"), tier=part_in.tier, parent_id=part_in.parent_id, reorder_point=part_in.reorder_point, @@ -644,6 +651,7 @@ def delete_part( "tracking_type": "tracking_type", "tracking type": "tracking_type", "tracking": "tracking_type", + "procurement": "procurement", "reorder_point": "reorder_point", "reorder point": "reorder_point", } @@ -674,6 +682,7 @@ class ImportRowPreview(BaseModel): category: str | None = None unit_of_measure: str | None = None tracking_type: str | None = None + procurement: str | None = None description: str | None = None reorder_point: float | None = None errors: list[str] = [] @@ -765,6 +774,7 @@ async def import_preview( preview.category = row_data.get("category") or None preview.unit_of_measure = row_data.get("unit_of_measure") or None preview.tracking_type = row_data.get("tracking_type") or None + preview.procurement = (row_data.get("procurement") or "").lower() or None preview.description = row_data.get("description") or None # Parse tier @@ -796,6 +806,11 @@ async def import_preview( f"Tracking type must be 'bulk' or 'serialized', got '{preview.tracking_type}'" ) + if preview.procurement and preview.procurement not in {"make", "buy", "both"}: + preview.errors.append( + f"Procurement must be 'make', 'buy', or 'both', got '{preview.procurement}'" + ) + # Check duplicates if preview.name and preview.name.lower() in existing_names: preview.warnings.append(f"Part with name '{preview.name}' already exists") @@ -856,6 +871,7 @@ def import_parts( category=part_in.category, unit_of_measure=part_in.unit_of_measure or "EA", tracking_type=part_in.tracking_type or "bulk", + procurement=part_in.procurement or ("buy" if part_in.external_pn else "make"), tier=part_in.tier, reorder_point=part_in.reorder_point, is_tooling=part_in.is_tooling, diff --git a/src/opal/api/routes/procedures.py b/src/opal/api/routes/procedures.py index 0f874bc..a3e449a 100644 --- a/src/opal/api/routes/procedures.py +++ b/src/opal/api/routes/procedures.py @@ -4,7 +4,7 @@ from decimal import Decimal from typing import Any -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Form, HTTPException, Query, UploadFile from pydantic import BaseModel, Field from sqlalchemy import func @@ -19,6 +19,7 @@ ProcedureType, ProcedureVersion, StepDependency, + StepImage, StepKit, UsageType, ) @@ -45,6 +46,7 @@ class StepSchema(BaseModel): estimated_duration_minutes: int | None = None required_role: str | None = None caution: str | None = None + strict_sequence: bool = False workcenter_id: int | None = None sub_steps: list["StepSchema"] = [] @@ -104,6 +106,7 @@ class StepCreate(BaseModel): estimated_duration_minutes: int | None = Field(None, ge=1) required_role: str | None = Field(None, max_length=50) caution: str | None = None + strict_sequence: bool = False parent_step_id: int | None = Field(None, description="Parent op ID for sub-steps") @@ -118,6 +121,7 @@ class StepUpdate(BaseModel): estimated_duration_minutes: int | None = Field(None, ge=1) required_role: str | None = Field(None, max_length=50) caution: str | None = None + strict_sequence: bool | None = None class StepReorder(BaseModel): @@ -202,6 +206,7 @@ def build_schema(step: ProcedureStep) -> StepSchema: estimated_duration_minutes=step.estimated_duration_minutes, required_role=step.required_role, caution=step.caution, + strict_sequence=step.strict_sequence, workcenter_id=step.workcenter_id, sub_steps=[build_schema(s) for s in sorted(sub_steps, key=lambda x: x.order)], ) @@ -490,6 +495,7 @@ def add_step( estimated_duration_minutes=data.estimated_duration_minutes, required_role=data.required_role, caution=data.caution, + strict_sequence=data.strict_sequence, ) db.add(step) db.flush() @@ -538,6 +544,8 @@ def update_step( step.required_role = data.required_role if "caution" in data.model_fields_set: step.caution = data.caution + if data.strict_sequence is not None: + step.strict_sequence = data.strict_sequence log_update(db, step, old_values, user_id) db.commit() @@ -578,6 +586,161 @@ def delete_step( db.commit() +# ============ Step images (authored reference imagery) ============ + + +class StepImageResponse(BaseModel): + """Authored step image.""" + + id: int + step_id: int + attachment_id: int + caption: str | None = None + position: int + + model_config = {"from_attributes": True} + + +class StepImageUpdate(BaseModel): + """Update a step image's caption.""" + + caption: str | None = None + + +def _get_procedure_step(db: DbSession, procedure_id: int, step_id: int) -> ProcedureStep: + step = ( + db.query(ProcedureStep) + .filter(ProcedureStep.id == step_id, ProcedureStep.procedure_id == procedure_id) + .first() + ) + if not step: + raise HTTPException(status_code=404, detail="Step not found") + return step + + +@router.get("/{procedure_id}/steps/{step_id}/images", response_model=list[StepImageResponse]) +def list_step_images( + procedure_id: int, + step_id: int, + db: DbSession, +) -> list[StepImageResponse]: + """List a step's authored reference images, in display order.""" + step = _get_procedure_step(db, procedure_id, step_id) + return [StepImageResponse.model_validate(img) for img in step.images] + + +@router.post( + "/{procedure_id}/steps/{step_id}/images", + response_model=StepImageResponse, + status_code=201, +) +async def add_step_image( + procedure_id: int, + step_id: int, + db: DbSession, + user_id: CurrentUserId, + file: UploadFile, + caption: str | None = Form(default=None), +) -> StepImageResponse: + """Attach an authored reference image to a step. + + Stored as an attachment (kind='step_image', procedure scope) plus a + StepImage link carrying caption and position. Published versions + snapshot the attachment id — the file outlives the link. + """ + step = _get_procedure_step(db, procedure_id, step_id) + + if not (file.content_type or "").startswith("image/"): + raise HTTPException(status_code=400, detail="Step images must be image files") + + from opal.api.routes.attachments import upload_attachment + + # Called as a plain function, not through FastAPI: the omitted Form(...) + # parameters would otherwise default to truthy sentinel objects, so pass + # them explicitly as None. + attachment = await upload_attachment( + db=db, + user_id=user_id, + file=file, + procedure_instance_id=None, + step_execution_id=None, + issue_id=None, + procedure_id=procedure_id, + kind="step_image", + note=None, + ) + + max_position = ( + db.query(func.max(StepImage.position)).filter(StepImage.step_id == step.id).scalar() + ) + image = StepImage( + step_id=step.id, + attachment_id=attachment.id, + caption=caption, + position=(max_position if max_position is not None else -1) + 1, + ) + db.add(image) + db.flush() + log_create(db, image, user_id) + db.commit() + db.refresh(image) + return StepImageResponse.model_validate(image) + + +@router.patch( + "/{procedure_id}/steps/{step_id}/images/{image_id}", + response_model=StepImageResponse, +) +def update_step_image( + procedure_id: int, + step_id: int, + image_id: int, + data: StepImageUpdate, + db: DbSession, + user_id: CurrentUserId, +) -> StepImageResponse: + """Update a step image's caption.""" + _get_procedure_step(db, procedure_id, step_id) + image = ( + db.query(StepImage).filter(StepImage.id == image_id, StepImage.step_id == step_id).first() + ) + if not image: + raise HTTPException(status_code=404, detail="Step image not found") + + old_values = get_model_dict(image) + if "caption" in data.model_fields_set: + image.caption = data.caption + log_update(db, image, old_values, user_id) + db.commit() + db.refresh(image) + return StepImageResponse.model_validate(image) + + +@router.delete("/{procedure_id}/steps/{step_id}/images/{image_id}", status_code=204) +def delete_step_image( + procedure_id: int, + step_id: int, + image_id: int, + db: DbSession, + user_id: CurrentUserId, +) -> None: + """Unlink an authored image from the step. + + The attachment row and file persist — published versions reference the + attachment id and must keep resolving. + """ + _get_procedure_step(db, procedure_id, step_id) + image = ( + db.query(StepImage).filter(StepImage.id == image_id, StepImage.step_id == step_id).first() + ) + if not image: + raise HTTPException(status_code=404, detail="Step image not found") + + log_delete(db, image, user_id) + db.delete(image) + db.commit() + + def _renumber_procedure_steps(steps: list[ProcedureStep]) -> None: """Recompute every step.step_number for a procedure based on the current `order` values. Normal top-level ops → "1", "2", ...; contingency top-level @@ -848,6 +1011,18 @@ def publish_version( if prereq_order is not None: depends_on_map.setdefault(d.step_id, []).append(prereq_order) + # Bulk-load authored step images; snapshotted by attachment id (files are + # immutable on disk and StepImage deletion only unlinks). + all_images = ( + db.query(StepImage) + .filter(StepImage.step_id.in_(step_ids)) + .order_by(StepImage.position) + .all() + ) + images_map: dict[int, list[StepImage]] = {} + for img in all_images: + images_map.setdefault(img.step_id, []).append(img) + # Create snapshot with hierarchical structure def step_to_dict(step: ProcedureStep) -> dict: return { @@ -864,8 +1039,13 @@ def step_to_dict(step: ProcedureStep) -> dict: "estimated_duration_minutes": step.estimated_duration_minutes, "required_role": step.required_role, "caution": step.caution, + "strict_sequence": step.strict_sequence, "workcenter_id": step.workcenter_id, "depends_on": sorted(depends_on_map.get(step.id, [])), + "images": [ + {"attachment_id": img.attachment_id, "caption": img.caption} + for img in images_map.get(step.id, []) + ], "step_kit": [ { "part_id": sk.part_id, @@ -1012,6 +1192,7 @@ def restore_from_version( estimated_duration_minutes=step_data.get("estimated_duration_minutes"), required_role=step_data.get("required_role"), caution=step_data.get("caution"), + strict_sequence=step_data.get("strict_sequence", False), workcenter_id=step_data.get("workcenter_id"), ) db.add(new_step) @@ -1036,6 +1217,7 @@ def restore_from_version( estimated_duration_minutes=step_data.get("estimated_duration_minutes"), required_role=step_data.get("required_role"), caution=step_data.get("caution"), + strict_sequence=step_data.get("strict_sequence", False), workcenter_id=step_data.get("workcenter_id"), ) db.add(new_step) @@ -1668,6 +1850,7 @@ def clone_procedure( estimated_duration_minutes=source_step.estimated_duration_minutes, required_role=source_step.required_role, caution=source_step.caution, + strict_sequence=source_step.strict_sequence, workcenter_id=source_step.workcenter_id, ) db.add(new_step) diff --git a/src/opal/api/routes/reports.py b/src/opal/api/routes/reports.py index f72caa9..0f39676 100644 --- a/src/opal/api/routes/reports.py +++ b/src/opal/api/routes/reports.py @@ -56,6 +56,7 @@ def export_parts_csv( "Category", "Description", "Unit", + "Procurement", "Total Quantity", "Locations", ] @@ -80,6 +81,7 @@ def export_parts_csv( part.category or "", (part.description or "")[:100], part.unit_of_measure or "", + part.procurement.value if hasattr(part.procurement, "value") else part.procurement, total_qty, locations, ] @@ -397,8 +399,8 @@ class ExecutionMetrics(BaseModel): total_executions: int completed: int - in_progress: int - pending: int + in_work: int + cut: int aborted: int avg_duration_minutes: float | None completion_rate: float @@ -425,8 +427,8 @@ def get_execution_metrics( total = len(instances) completed = sum(1 for i in instances if _get_status(i) == "completed") - in_progress = sum(1 for i in instances if _get_status(i) == "in_progress") - pending = sum(1 for i in instances if _get_status(i) == "pending") + in_work = sum(1 for i in instances if _get_status(i) == "in_work") + cut = sum(1 for i in instances if _get_status(i) == "cut") aborted = sum(1 for i in instances if _get_status(i) == "aborted") # Calculate average duration for completed instances @@ -441,8 +443,8 @@ def get_execution_metrics( return ExecutionMetrics( total_executions=total, completed=completed, - in_progress=in_progress, - pending=pending, + in_work=in_work, + cut=cut, aborted=aborted, avg_duration_minutes=round(avg_duration, 1) if avg_duration else None, completion_rate=round(completion_rate, 1), @@ -450,13 +452,15 @@ def get_execution_metrics( class IssueMetrics(BaseModel): - """Issue tracking metrics.""" + """Issue tracking metrics. The disposition gate exists only above + advisory containment, so open = advisory_open + undispositioned + + dispositioned.""" total_issues: int open: int - investigating: int - disposition_pending: int - disposition_approved: int + advisory_open: int + undispositioned: int + dispositioned: int closed: int by_type: dict[str, int] by_priority: dict[str, int] @@ -480,9 +484,9 @@ def get_issue_metrics( total = len(issues) open_count = sum(1 for i in issues if _get_issue_status(i) == "open") - investigating = sum(1 for i in issues if _get_issue_status(i) == "investigating") - disposition_pending = sum(1 for i in issues if _get_issue_status(i) == "disposition_pending") - disposition_approved = sum(1 for i in issues if _get_issue_status(i) == "disposition_approved") + advisory_open = sum(1 for i in issues if i.disp_state == "open") + undispositioned = sum(1 for i in issues if i.disp_state == "undispositioned") + dispositioned = sum(1 for i in issues if i.disp_state == "dispositioned") closed = sum(1 for i in issues if _get_issue_status(i) == "closed") by_type: dict[str, int] = {} @@ -500,9 +504,9 @@ def get_issue_metrics( return IssueMetrics( total_issues=total, open=open_count, - investigating=investigating, - disposition_pending=disposition_pending, - disposition_approved=disposition_approved, + advisory_open=advisory_open, + undispositioned=undispositioned, + dispositioned=dispositioned, closed=closed, by_type=by_type, by_priority=by_priority, diff --git a/src/opal/api/routes/requirements.py b/src/opal/api/routes/requirements.py index 4206276..143ff4e 100644 --- a/src/opal/api/routes/requirements.py +++ b/src/opal/api/routes/requirements.py @@ -324,6 +324,9 @@ def update_requirement( ) old_values = get_model_dict(req) + # Nullable fields clear on explicit null (model_fields_set distinguishes + # absent from null — issue #30); non-nullable fields keep the None-skip. + clearable = {"rationale", "category", "verification_method", "tbr_owner_id", "tbr_due"} for field in ( "title", "statement", @@ -339,7 +342,10 @@ def update_requirement( "lifecycle_state", ): value = getattr(data, field) - if value is not None: + if field in clearable: + if field in data.model_fields_set: + setattr(req, field, value) + elif value is not None: setattr(req, field, value) # Any edit is by definition a fresh look — clear staleness (spec §7). diff --git a/src/opal/api/routes/search.py b/src/opal/api/routes/search.py index f607062..0f0b672 100644 --- a/src/opal/api/routes/search.py +++ b/src/opal/api/routes/search.py @@ -81,11 +81,25 @@ def _procedure_result(p: MasterProcedure) -> SearchResult: def _execution_result(inst: ProcedureInstance) -> SearchResult: + # The work order number is the execution's name; a database id never + # renders (F12). WO-less cuts fall back to procedure name + cut date. + procedure_name = inst.procedure.name if inst.procedure else None + if inst.work_order_number: + label = inst.work_order_number + sublabel = procedure_name + else: + cut = inst.created_at.date().isoformat() if inst.created_at else None + label = ( + f"{procedure_name or 'Execution'} (cut {cut})" + if cut + else (procedure_name or "Execution") + ) + sublabel = None return SearchResult( entity_type="execution", id=inst.id, - label=f"Execution #{inst.id}", - sublabel=inst.work_order_number, + label=label, + sublabel=sublabel, url=f"/executions/{inst.id}", status=_status_value(inst.status), ) diff --git a/src/opal/api/routes/welcome.py b/src/opal/api/routes/welcome.py index 1fbaeae..17615e9 100644 --- a/src/opal/api/routes/welcome.py +++ b/src/opal/api/routes/welcome.py @@ -30,9 +30,9 @@ def load_demo_data( db: DbSession, user: RequiredUser, ) -> JSONResponse: - """Enter the demo: a separate throwaway database seeded with Project - Kestrel. The real database is untouched; exiting deletes the demo file. - Admin only. + """Enter the demo: a separate throwaway database seeded with the Mojave + Sphinx dataset. The real database is untouched; exiting deletes the demo + file. Admin only. """ if not user.is_admin: raise HTTPException(status_code=403, detail="Admin access required") diff --git a/src/opal/core/events.py b/src/opal/core/events.py index 6ac3223..46e4926 100644 --- a/src/opal/core/events.py +++ b/src/opal/core/events.py @@ -21,11 +21,14 @@ class EventType(str, Enum): """Types of real-time events.""" # Execution events - STEP_STARTED = "step_started" + CURSOR_MOVED = "cursor_moved" STEP_COMPLETED = "step_completed" INSTANCE_STARTED = "instance_started" INSTANCE_COMPLETED = "instance_completed" + # Issue events + ISSUE_DISPOSITIONED = "issue_dispositioned" + # Collaboration events USER_JOINED = "user_joined" USER_LEFT = "user_left" @@ -133,15 +136,15 @@ def subscriber_count(self) -> int: # Helper functions for publishing common events -async def emit_step_started( +async def emit_cursor_moved( instance_id: int, step_number: int, user_id: int | None = None, user_name: str | None = None, ) -> None: - """Emit a step_started event.""" + """Emit a cursor_moved presence event (broadcast only — never recorded).""" event = Event( - type=EventType.STEP_STARTED, + type=EventType.CURSOR_MOVED, data={ "instance_id": instance_id, "step_number": step_number, @@ -207,6 +210,24 @@ async def emit_instance_completed( await event_bus.publish(event) +async def emit_issue_dispositioned( + instance_id: int, + issue_id: int, + issue_number: str, +) -> None: + """Emit an issue_dispositioned event — held rows on execution pages + recover their controls without reload.""" + event = Event( + type=EventType.ISSUE_DISPOSITIONED, + data={ + "instance_id": instance_id, + "issue_id": issue_id, + "issue_number": issue_number, + }, + ) + await event_bus.publish_to_instance(instance_id, event) + + async def emit_user_joined( instance_id: int, user_id: int, diff --git a/src/opal/core/execution_flow.py b/src/opal/core/execution_flow.py new file mode 100644 index 0000000..1d18abd --- /dev/null +++ b/src/opal/core/execution_flow.py @@ -0,0 +1,857 @@ +"""Execution flow: presence, hold gating, completion, document state. + +The single home for the commitment-moment rules of the execution document. +COMPLETE is the commitment moment — it checks its dependencies' states here, +and both the JSON API routes and the MCP tools call these functions rather +than carrying their own copies. Presence is focus: a user's cursor position +is broadcast state, never an event — moving it records nothing. + +Hold doctrine (Issues R2 §2/§9): the blocking predicate is *undispositioned*, +never "open". A hold is derived state — it lifts the moment its issue reaches +a terminal disposition, with no status choreography. Two gate kinds exist +today, both gating COMPLETE (a hold cannot physically prevent starting work, +so it does not pretend to): +- containment scope (step/op/wo): an undispositioned issue holds its scope's + COMPLETE — the raised step and its OP, the whole OP, or WO close-out. +- boundary ("resolve by", ``containment_step_id``): a step-contained issue + bound to a later step holds that step's COMPLETE. +The blocking computation has one home: core/holds. +""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.orm import Session + +from opal.core.audit import log_create +from opal.core.holds import _val as _status_value # enum-unwrap, one home in core/holds +from opal.core.holds import blocking_issues_for_instance, get_hold_state +from opal.db.models.attachment import Attachment +from opal.db.models.execution import ( + InstanceStatus, + ProcedureInstance, + StepExecution, + StepFocus, + StepNote, + StepStatus, +) +from opal.db.models.inventory import InventoryProduction, ProductionStatus +from opal.db.models.issue import Issue +from opal.db.models.procedure import ProcedureVersion +from opal.db.models.user import User + +TERMINAL_STEP_STATUSES = { + StepStatus.COMPLETED.value, + StepStatus.SIGNED_OFF.value, + StepStatus.SKIPPED.value, +} + +# Roster chips gray out when a participant's heartbeat goes quiet (tablets +# sleep; the cursor persists). +PRESENCE_STALE_SECONDS = 60 + + +class FlowError(Exception): + """A commitment-moment rule refused the action.""" + + status_code = 400 + + def __init__(self, message: str): + super().__init__(message) + self.message = message + + +def user_initials(name: str | None) -> str: + if not name: + return "?" + parts = [p for p in name.split() if p] + if not parts: + return "?" + if len(parts) == 1: + return parts[0][:2].upper() + return (parts[0][0] + parts[-1][0]).upper() + + +def version_step_map(version: ProcedureVersion | None) -> dict[int, dict]: + if version is None: + return {} + return {s["order"]: s for s in version.content.get("steps", [])} + + +def step_display(step_exec: StepExecution) -> str: + return step_exec.step_number_str or str(step_exec.step_number) + + +# ============ Hold lookups ============ + + +def holding_ncs_by_step(db: Session, instance_id: int) -> dict[int, list[Issue]]: + """Issues holding each row's COMPLETE (step/op containment), keyed by + step_execution_id. Derived from containment — the one home for the + blocking computation is core/holds.""" + state = get_hold_state(db, instance_id) + merged: dict[int, list[Issue]] = {} + for se_id, issues in state.complete_blocked.items(): + merged.setdefault(se_id, []).extend(issues) + for se_id, issues in state.op_complete_blocked.items(): + bucket = merged.setdefault(se_id, []) + bucket.extend(i for i in issues if all(x.id != i.id for x in bucket)) + return merged + + +def bound_blocks_by_step(db: Session, instance_id: int) -> dict[int, list[Issue]]: + """Boundary holds ("resolve by"): issues holding a future step's COMPLETE, + keyed by that step's execution id.""" + return dict(get_hold_state(db, instance_id).start_blocked) + + +# ============ Gating ============ + + +@dataclass +class Blocker: + """One reason a commitment moment refuses.""" + + kind: str # hold | nc | sequence | dependency | redline | parent_hold + message: str + issue_id: int | None = None + issue_number: str | None = None + + +def _exec_lookup(instance: ProcedureInstance) -> dict[int, StepExecution]: + return {se.step_number: se for se in instance.step_executions} + + +def sequence_blockers( + db: Session, + instance: ProcedureInstance, + step_exec: StepExecution, +) -> list[Blocker]: + """Structural gates on this step's COMPLETE: strict_sequence order, + declared OP dependencies, open redlines. Containment holds live in + held_scope_blockers.""" + blockers: list[Blocker] = [] + exec_lookup = _exec_lookup(instance) + version = db.get(ProcedureVersion, instance.version_id) + vs_map = version_step_map(version) + + # Gate against the op-level entry — for sub-steps that's the parent op. + gate_op_order: int | None = None + if step_exec.level == 0: + gate_op_order = step_exec.step_number + # A parent OP commits only after its sub-steps: manual COMPLETE of an + # OP row with open children is refused (the auto-complete path already + # requires all children terminal — the manual path must agree). + open_children = sorted( + ( + se + for se in instance.step_executions + if se.parent_step_order == step_exec.step_number + and _status_value(se.status) not in TERMINAL_STEP_STATUSES + ), + key=lambda se: se.step_number, + ) + if open_children: + # Display numbers (step_display), never the document-global order — + # a child stored at global order 6 renders as 5.1. + labels = ", ".join(step_display(se) for se in open_children) + blockers.append(Blocker(kind="children", message=f"Waiting on sub-steps {labels}")) + elif step_exec.parent_step_order is not None: + gate_op_order = step_exec.parent_step_order + + # strict_sequence: sub-step N requires N-1 terminal. + parent_vs = vs_map.get(gate_op_order) or {} + if parent_vs.get("strict_sequence"): + prior = [ + se + for se in instance.step_executions + if se.parent_step_order == gate_op_order + and se.ad_hoc_issue_id is None + and se.step_number < step_exec.step_number + ] + unmet = [ + step_display(se) + for se in sorted(prior, key=lambda se: se.step_number) + if _status_value(se.status) not in TERMINAL_STEP_STATUSES + ] + if unmet: + blockers.append(Blocker(kind="sequence", message="Waiting on " + ", ".join(unmet))) + + if gate_op_order is not None: + # Declared OP dependencies from the version snapshot. + version_step = vs_map.get(gate_op_order) + dep_orders = (version_step or {}).get("depends_on") or [] + dep_blockers: list[str] = [] + for dep_order in dep_orders: + prereq = exec_lookup.get(dep_order) + if prereq is None: + continue + if _status_value(prereq.status) not in TERMINAL_STEP_STATUSES: + dep_blockers.append(step_display(prereq)) + if dep_blockers: + blockers.append( + Blocker(kind="dependency", message="Waiting on OP " + ", ".join(dep_blockers)) + ) + + # Incomplete redline ops attached to the gate op must finish first. + redline_rows = ( + db.query(StepExecution) + .join(Issue, Issue.id == StepExecution.ad_hoc_issue_id) + .filter( + StepExecution.instance_id == instance.id, + StepExecution.ad_hoc_host_order == gate_op_order, + StepExecution.level == 0, + Issue.deleted_at.is_(None), + ) + .all() + ) + redline_blockers = [ + step_display(r) + for r in redline_rows + if _status_value(r.status) not in TERMINAL_STEP_STATUSES + ] + if redline_blockers: + blockers.append( + Blocker( + kind="redline", + message="Waiting on redline op " + ", ".join(redline_blockers), + ) + ) + + return blockers + + +def held_scope_blockers( + db: Session, + instance: ProcedureInstance, + step_exec: StepExecution, +) -> list[Blocker]: + """Undispositioned issues holding this row's scope. + + Containment-derived (core/holds): step/op containment in the row's + scope, plus a boundary hold bound to this row ("resolve by") — held + work cannot be completed or skipped around. + """ + state = get_hold_state(db, instance.id) + issues = state.blockers_for_complete(step_exec) + state.blockers_for_start(step_exec) + blockers: list[Blocker] = [] + seen: set[int] = set() + for issue in issues: + if issue.id in seen: + continue + seen.add(issue.id) + blockers.append( + Blocker( + kind="nc", + message=f"Undispositioned {issue.issue_number}", + issue_id=issue.id, + issue_number=issue.issue_number, + ) + ) + return blockers + + +def complete_blockers( + db: Session, + instance: ProcedureInstance, + step_exec: StepExecution, +) -> list[Blocker]: + """Everything holding this step's (or OP's) COMPLETE: the held scope + plus the structural sequence gates.""" + return held_scope_blockers(db, instance, step_exec) + sequence_blockers(db, instance, step_exec) + + +def skip_blockers( + db: Session, + instance: ProcedureInstance, + step_exec: StepExecution, +) -> list[Blocker]: + """Everything holding this step's SKIP: the held scope, any open + redline-rework op on the gate, and — for a parent OP row — its open + children. + + SKIP is a terminal commitment, so it inherits the held scope (a hold + cannot be skipped around), the redline gate — skipping the host step + must not strand an authorized rework op — and the children gate: a + terminal parent over live children strands them, exactly the state + COMPLETE refuses. It deliberately omits strict_sequence and + OP-dependency ordering: skipping legitimately does not require + predecessors to be done.""" + scope = held_scope_blockers(db, instance, step_exec) + structural = [ + b for b in sequence_blockers(db, instance, step_exec) if b.kind in ("redline", "children") + ] + return scope + structural + + +# ============ Presence (focus) ============ + + +def instance_cursors(db: Session, instance_id: int) -> list[StepFocus]: + return db.query(StepFocus).filter(StepFocus.instance_id == instance_id).all() + + +def focus_step( + db: Session, + instance: ProcedureInstance, + step_exec: StepExecution, + user: User, +) -> StepFocus: + """Move a user's cursor to a step. Presence only: no gate checks, no + audit event — the only durable side effect is first-focus telemetry.""" + now = datetime.now(UTC) + # Atomic upsert: two tabs posting the same user's first focus must not + # race the unique (instance, user) row into an IntegrityError. + stmt = ( + sqlite_insert(StepFocus) + .values( + instance_id=instance.id, + user_id=user.id, + step_execution_id=step_exec.id, + focused_at=now, + created_at=now, + updated_at=now, + ) + .on_conflict_do_update( + index_elements=[StepFocus.instance_id, StepFocus.user_id], + set_={"step_execution_id": step_exec.id, "focused_at": now, "updated_at": now}, + ) + ) + db.execute(stmt) + if step_exec.first_focused_at is None: + step_exec.first_focused_at = now + db.flush() + return ( + db.query(StepFocus) + .filter(StepFocus.instance_id == instance.id, StepFocus.user_id == user.id) + .one() + ) + + +def clear_focus(db: Session, instance_id: int, user_id: int) -> None: + """Drop a user's cursor (leaving the document).""" + db.query(StepFocus).filter( + StepFocus.instance_id == instance_id, StepFocus.user_id == user_id + ).delete() + + +PRESENCE_TOUCH_SECONDS = 30 + + +def touch_user_presence(db: Session, user_id: int | None) -> None: + """The 5s state poll doubles as the presence heartbeat; writes are + throttled so polling stays read-mostly. + + Commits the request session: callers must invoke this only at a point + where the transaction holds no unrelated pending writes (the state GET + calls it before building the payload).""" + if user_id is None: + return + user = db.get(User, user_id) + if user is None: + return + now = datetime.now(UTC) + last = user.last_seen_at + if last is not None and last.tzinfo is None: + last = last.replace(tzinfo=UTC) + if last is None or (now - last).total_seconds() > PRESENCE_TOUCH_SECONDS: + user.last_seen_at = now + db.commit() + + +def mark_instance_in_work(db: Session, instance: ProcedureInstance) -> bool: + """First completed/skipped work moves the WO out of CUT. Derived from + the event — there is no separate start moment.""" + if _status_value(instance.status) != InstanceStatus.CUT.value: + return False + instance.status = InstanceStatus.IN_WORK + instance.started_at = datetime.now(UTC) + db.query(InventoryProduction).filter( + InventoryProduction.procedure_instance_id == instance.id, + InventoryProduction.status == ProductionStatus.PLANNED, + ).update({InventoryProduction.status: ProductionStatus.WIP}) + return True + + +# ============ Step notes ============ + + +def add_step_note( + db: Session, + step_exec: StepExecution, + body: str | None, + author_id: int | None, +) -> StepNote: + """Append a timestamped, authored note to a step. The ONE creation path — + JSON API, MCP, and the complete flow all land here. + + Notes are a record, not a control: no status gate, no instance gate — a + pending, held, or terminal step on a completed or aborted work order can + still take a note (post-mortem debugging is the point). + """ + text = (body or "").strip() + if not text: + raise FlowError("Note body is empty") + note = StepNote(step_execution_id=step_exec.id, author_id=author_id, body=text) + db.add(note) + db.flush() + log_create(db, note, author_id) + return note + + +def notes_by_step(db: Session, step_exec_ids: list[int]) -> dict[int, list[StepNote]]: + """Chronological notes keyed by step_execution_id.""" + grouped: dict[int, list[StepNote]] = {} + if not step_exec_ids: + return grouped + for note in ( + db.query(StepNote) + .filter(StepNote.step_execution_id.in_(step_exec_ids)) + .order_by(StepNote.created_at.asc(), StepNote.id.asc()) + .all() + ): + grouped.setdefault(note.step_execution_id, []).append(note) + return grouped + + +# ============ Completion ============ + + +def validate_data_captured( + version: ProcedureVersion | None, + step_exec: StepExecution, + data_captured: dict[str, Any] | None, +) -> list[str]: + """Required/min/max validation against the step's data schema. + + Validates whenever the step declares a schema — absent data fails + required fields rather than skipping validation. + """ + schema = step_exec.required_data_schema + if schema is None and version is not None: + step_data = version_step_map(version).get(step_exec.step_number) or {} + schema = step_data.get("required_data_schema") + fields = (schema or {}).get("fields", []) + if not fields: + return [] + + data = data_captured or {} + errors: list[str] = [] + for field_def in fields: + name = field_def.get("name") + label = field_def.get("label", name) + val = data.get(name) + if field_def.get("required") and ( + val is None or val == "" or (isinstance(val, list) and not val) + ): + errors.append(f"{label} is required") + if field_def.get("type") == "number" and val is not None and val != "": + try: + num_val = float(val) + except (TypeError, ValueError): + errors.append(f"{label}: invalid number") + continue + if field_def.get("min") is not None and num_val < field_def["min"]: + errors.append(f"{label}: {num_val} below minimum {field_def['min']}") + if field_def.get("max") is not None and num_val > field_def["max"]: + errors.append(f"{label}: {num_val} above maximum {field_def['max']}") + return errors + + +@dataclass +class CompleteResult: + step_exec: StepExecution + instance_completed: bool = False + instance_started: bool = False # this event moved the WO out of CUT + validation_errors: list[str] = field(default_factory=list) + + +def complete_step_flow( + db: Session, + instance: ProcedureInstance, + step_exec: StepExecution, + user_id: int, + data_captured: dict[str, Any] | None = None, + notes: str | None = None, +) -> CompleteResult: + """COMPLETE is the commitment moment: it checks the undispositioned-issue + and sequence state of its scope, validates required data, and cascades + instance completion.""" + inst_status = _status_value(instance.status) + if inst_status not in (InstanceStatus.CUT.value, InstanceStatus.IN_WORK.value): + raise FlowError("Instance is not active") + + step_status = _status_value(step_exec.status) + if step_status in (StepStatus.COMPLETED.value, StepStatus.SIGNED_OFF.value): + raise FlowError("Step already completed") + if step_status not in ( + StepStatus.PENDING.value, + StepStatus.IN_PROGRESS.value, + StepStatus.AWAITING_SIGNOFF.value, + ): + raise FlowError(f"Cannot complete step in {step_status} status") + + holds = complete_blockers(db, instance, step_exec) + if holds: + raise FlowError("Cannot complete: " + "; ".join(b.message for b in holds)) + + version = db.get(ProcedureVersion, instance.version_id) + errors = validate_data_captured(version, step_exec, data_captured) + if errors: + return CompleteResult(step_exec=step_exec, validation_errors=errors) + + instance_started = mark_instance_in_work(db, instance) + + # Re-assert the precondition as a guarded UPDATE: two users completing + # the same step inside one poll window serialize at the write, and the + # loser gets a clean refusal instead of double-writing completed_by. + now = datetime.now(UTC) + flipped = ( + db.query(StepExecution) + .filter( + StepExecution.id == step_exec.id, + StepExecution.status.in_( + [StepStatus.PENDING, StepStatus.IN_PROGRESS, StepStatus.AWAITING_SIGNOFF] + ), + ) + .update( + { + StepExecution.status: StepStatus.COMPLETED, + StepExecution.completed_at: now, + StepExecution.completed_by_id: user_id, + }, + synchronize_session=False, + ) + ) + if not flipped: + db.rollback() + raise FlowError("Step already completed") + db.expire(step_exec) + + if step_status == StepStatus.PENDING.value and step_exec.started_at is None: + step_exec.started_at = now + if data_captured: + step_exec.data_captured = data_captured + if notes and notes.strip(): + add_step_note(db, step_exec, notes, user_id) + + old_instance_status = _status_value(instance.status) + check_instance_completion(db, instance) + + instance_completed = ( + old_instance_status != InstanceStatus.COMPLETED.value + and _status_value(instance.status) == InstanceStatus.COMPLETED.value + ) + return CompleteResult( + step_exec=step_exec, + instance_completed=instance_completed, + instance_started=instance_started, + ) + + +def check_instance_completion(db: Session, instance: ProcedureInstance) -> None: + """Auto-complete parent OPs whose children are done, then the instance. + + An OP holding undispositioned NCs anywhere in its scope never + auto-completes — OP COMPLETE is gated by disposition (Issues R2 §2). + """ + version = db.get(ProcedureVersion, instance.version_id) + if not version: + return + + version_steps = {s["order"]: s for s in version.content.get("steps", [])} + all_steps = db.query(StepExecution).filter(StepExecution.instance_id == instance.id).all() + hold_state = get_hold_state(db, instance.id) + + def _row_held(se: StepExecution) -> bool: + # A bound "resolve by" hold (start_blocked) gates a row's COMPLETE just + # as a raised/op hold does — fold both so an OP never auto-completes, + # and the WO never closes, over an open hold anchored on a terminal row. + return bool(hold_state.blockers_for_complete(se) or hold_state.blockers_for_start(se)) + + for step_exec in all_steps: + if step_exec.level == 0: + children = [s for s in all_steps if s.parent_step_order == step_exec.step_number] + if children: + step_status = _status_value(step_exec.status) + if step_status in (StepStatus.PENDING.value, StepStatus.IN_PROGRESS.value): + all_children_done = all( + _status_value(c.status) + in (StepStatus.COMPLETED.value, StepStatus.SKIPPED.value) + for c in children + ) + scope_held = _row_held(step_exec) or any(_row_held(c) for c in children) + if all_children_done and not scope_held: + step_exec.status = StepStatus.COMPLETED + step_exec.completed_at = datetime.now(UTC) + + def _to_aware(dt: datetime) -> datetime: + return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt + + last_child = max( + (c for c in children if c.completed_at), + key=lambda c: _to_aware(c.completed_at), + default=None, + ) + if last_child and last_child.completed_by_id: + step_exec.completed_by_id = last_child.completed_by_id + + for step_exec in all_steps: + step_data = version_steps.get(step_exec.step_number, {}) + is_contingency = step_data.get("is_contingency", False) + step_status = _status_value(step_exec.status) + + if not is_contingency and step_status not in TERMINAL_STEP_STATUSES: + return + # A touched contingency step (worked or held) blocks completion; + # an untouched one does not. + if is_contingency and step_status in ( + StepStatus.IN_PROGRESS.value, + StepStatus.ON_HOLD.value, + ): + return + + # Close-out is authoritative on the issue set, not on per-row hold buckets: + # ANY non-advisory, containment-bearing, undispositioned issue open on this + # work order keeps it open — a wo-scoped hold, an op/step hold whose anchor + # already went terminal, or a "resolve by" boundary bound to a terminal or + # OP row. Inferring close-out from wo_blocked alone let those slip through + # (a WO closing COMPLETED over an open NC). Dispositioned/advisory issues + # are excluded here, so legitimate completion is never deadlocked. + if blocking_issues_for_instance(db, instance.id): + return + + instance.status = InstanceStatus.COMPLETED + instance.completed_at = datetime.now(UTC) + + +# ============ Document state ============ + + +def build_execution_state(db: Session, instance: ProcedureInstance) -> dict[str, Any]: + """The controller's view as JSON: steps, states, presence, holds. + + One builder serves the web presence poll and the MCP + get_execution_state tool — same facts, same home. + """ + version = db.get(ProcedureVersion, instance.version_id) + vs_map = version_step_map(version) + now = datetime.now(UTC) + + # Legacy rows may lack parent_step_order — derive from the snapshot. + id_to_order = {vs.get("id"): vs["order"] for vs in vs_map.values()} + snapshot_parent = { + vs["order"]: id_to_order.get(vs.get("parent_step_id")) for vs in vs_map.values() + } + + def parent_order_of(se: StepExecution) -> int | None: + if se.parent_step_order is not None: + return se.parent_step_order + return snapshot_parent.get(se.step_number) + + cursors = instance_cursors(db, instance.id) + cursors_by_step: dict[int, list[StepFocus]] = {} + for cursor in sorted(cursors, key=lambda c: c.focused_at): + cursors_by_step.setdefault(cursor.step_execution_id, []).append(cursor) + hold_state = get_hold_state(db, instance.id) + raised: dict[int, list[Issue]] = {} + for se_id, issues in hold_state.complete_blocked.items(): + raised.setdefault(se_id, []).extend(issues) + for se_id, issues in hold_state.op_complete_blocked.items(): + bucket = raised.setdefault(se_id, []) + bucket.extend(i for i in issues if all(x.id != i.id for x in bucket)) + bound = hold_state.start_blocked + + step_exec_ids = [se.id for se in instance.step_executions] + step_notes = notes_by_step(db, step_exec_ids) + + user_ids = {c.user_id for c in cursors} + for p in instance.participants or []: + if p.get("user_id"): + user_ids.add(p["user_id"]) + completer_ids = {se.completed_by_id for se in instance.step_executions if se.completed_by_id} + author_ids = {n.author_id for notes in step_notes.values() for n in notes if n.author_id} + lookup_ids = user_ids | completer_ids | author_ids + users = db.query(User).filter(User.id.in_(lookup_ids)).all() if lookup_ids else [] + user_lookup = {u.id: u for u in users} + + def _iso(dt: datetime | None) -> str | None: + return dt.isoformat() if dt else None + + def _stale(user: User | None) -> bool: + if user is None or user.last_seen_at is None: + return True + last_seen = user.last_seen_at + if last_seen.tzinfo is None: + last_seen = last_seen.replace(tzinfo=UTC) + return (now - last_seen).total_seconds() > PRESENCE_STALE_SECONDS + + def _cursor_entry(cursor: StepFocus) -> dict[str, Any]: + user = user_lookup.get(cursor.user_id) + return { + "user_id": cursor.user_id, + "name": user.name if user else None, + "initials": user_initials(user.name if user else None), + "focused_at": _iso(cursor.focused_at), + "stale": _stale(user), + } + + # Attachment counts per step (evidence indicator "n ATT"). + attach_counts: dict[int, int] = {} + if step_exec_ids: + for att_step_id, count in ( + db.query(Attachment.step_execution_id, func.count()) + .filter(Attachment.step_execution_id.in_(step_exec_ids)) + .group_by(Attachment.step_execution_id) + .all() + ): + attach_counts[att_step_id] = count + + steps: list[dict[str, Any]] = [] + done = 0 + total = 0 + for se in sorted(instance.step_executions, key=lambda s: s.step_number): + vs = vs_map.get(se.step_number) or {} + status = _status_value(se.status) + step_cursors = cursors_by_step.get(se.id, []) + completer = user_lookup.get(se.completed_by_id) if se.completed_by_id else None + + is_leaf = se.level > 0 or not any( + parent_order_of(other) == se.step_number + for other in instance.step_executions + if other.level > 0 + ) + if is_leaf and not vs.get("is_contingency", False): + total += 1 + if status in TERMINAL_STEP_STATUSES: + done += 1 + + steps.append( + { + "order": se.step_number, + "number": step_display(se), + "level": se.level, + "parent_order": parent_order_of(se), + "title": se.title or vs.get("title"), + "status": status, + "role": vs.get("required_role"), + "is_ad_hoc": se.ad_hoc_issue_id is not None, + "cursors": [_cursor_entry(c) for c in step_cursors], + "completed": { + "by": completer.name if completer else None, + "initials": user_initials(completer.name if completer else None), + "at": _iso(se.completed_at), + } + if status in (StepStatus.COMPLETED.value, StepStatus.SIGNED_OFF.value) + else None, + "holds": [ + { + "issue_id": i.id, + "issue_number": i.issue_number, + "kind": "bound", + "disposition_state": i.disp_state, + } + for i in bound.get(se.id, []) + ] + + [ + { + "issue_id": i.id, + "issue_number": i.issue_number, + "kind": "raised", + "disposition_state": i.disp_state, + } + for i in raised.get(se.id, []) + ], + "attachments": attach_counts.get(se.id, 0), + "notes": [ + { + "id": n.id, + "author": ( + user_lookup[n.author_id].name + if n.author_id and n.author_id in user_lookup + else None + ), + "created_at": _iso(n.created_at), + "body": n.body, + } + for n in step_notes.get(se.id, []) + ], + } + ) + + # Roster: cursor holders first ("name @step"), then joined users whose + # cursor isn't placed yet. + roster: list[dict[str, Any]] = [] + step_by_id = {se.id: se for se in instance.step_executions} + for cursor in sorted(cursors, key=lambda c: c.focused_at): + user = user_lookup.get(cursor.user_id) + se = step_by_id.get(cursor.step_execution_id) + roster.append( + { + "user_id": cursor.user_id, + "name": user.name if user else None, + "initials": user_initials(user.name if user else None), + "step_order": se.step_number if se else None, + "step_number": step_display(se) if se else None, + "focused_at": _iso(cursor.focused_at), + "stale": _stale(user), + } + ) + cursor_user_ids = {c.user_id for c in cursors} + for p in instance.participants or []: + uid = p.get("user_id") + if uid is None or uid in cursor_user_ids: + continue + user = user_lookup.get(uid) + roster.append( + { + "user_id": uid, + "name": user.name if user else p.get("user_name"), + "initials": user_initials(user.name if user else p.get("user_name")), + "step_order": None, + "step_number": None, + "focused_at": None, + "stale": _stale(user), + } + ) + + # Rail holds: undispositioned first, one entry per issue. + holds_by_issue: dict[int, dict[str, Any]] = {} + for se_id, issues in list(bound.items()) + list(raised.items()): + se = step_by_id.get(se_id) + for issue in issues: + entry = holds_by_issue.setdefault( + issue.id, + { + "issue_id": issue.id, + "issue_number": issue.issue_number, + "title": issue.title, + "disposition_state": issue.disp_state, + "blocks": [], + }, + ) + if se is not None: + num = step_display(se) + if num not in entry["blocks"]: + entry["blocks"].append(num) + + return { + "instance": { + "id": instance.id, + "work_order": instance.work_order_number, + "status": _status_value(instance.status), + "procedure_name": instance.procedure.name if instance.procedure else None, + "version_number": version.version_number if version else None, + "progress": {"done": done, "total": total}, + }, + "steps": steps, + "roster": roster, + "holds": sorted( + holds_by_issue.values(), + key=lambda h: (h["disposition_state"] != "undispositioned", h["issue_number"] or ""), + ), + "generated_at": now.isoformat(), + } diff --git a/src/opal/core/holds.py b/src/opal/core/holds.py new file mode 100644 index 0000000..19a55d9 --- /dev/null +++ b/src/opal/core/holds.py @@ -0,0 +1,256 @@ +"""Containment-derived hold computation. + +Holds are never stored — they are derived, at the commitment moments +(COMPLETE, START, SKIP, instance close-out), from undispositioned issues +and their containment scope: + + containment while undispositioned… + step the raised/bound step cannot COMPLETE (and its OP cannot + COMPLETE over it); a bound future step ("resolve by") + cannot COMPLETE either — there is no start moment to gate + op every step in the OP may run, but the OP cannot COMPLETE + wo the work order cannot COMPLETE/close out + advisory blocks nothing + +Signing a disposition releases every containment the issue holds, live: +there is no stored state to flip back. +""" + +from dataclasses import dataclass, field + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from opal.db.models.execution import ProcedureInstance, StepExecution +from opal.db.models.issue import Containment, Issue, IssueStatus + + +def _val(obj: object) -> str | None: + """Unwrap a potentially-enum attribute to its string value.""" + if obj is None: + return None + return obj.value if hasattr(obj, "value") else obj # type: ignore[return-value] + + +def blocking_issues_for_instance(db: Session, instance_id: int) -> list[Issue]: + """Undispositioned, non-advisory issues linked to this work order.""" + return ( + db.query(Issue) + .filter( + Issue.procedure_instance_id == instance_id, + Issue.deleted_at.is_(None), + Issue.status != IssueStatus.CLOSED, + Issue.containment != Containment.ADVISORY, + or_(Issue.disposition_type.is_(None), Issue.dispositioned_at.is_(None)), + ) + .order_by(Issue.id) + .all() + ) + + +@dataclass +class HoldState: + """Derived blocking state for one work order.""" + + # step_execution_id -> issues blocking that step's COMPLETE (and SKIP) + complete_blocked: dict[int, list[Issue]] = field(default_factory=dict) + # step_execution_id -> issues holding a bound future step ("resolve by"). + # Named start_blocked for its R2 lineage; under the focus model these + # gate the bound step's COMPLETE (execution_flow folds them in). + start_blocked: dict[int, list[Issue]] = field(default_factory=dict) + # op-level step_execution_id -> issues blocking that OP's COMPLETE + op_complete_blocked: dict[int, list[Issue]] = field(default_factory=dict) + # issues blocking the work order's completion/close-out + wo_blocked: list[Issue] = field(default_factory=list) + + def blockers_for_complete(self, step_exec: StepExecution) -> list[Issue]: + """Issues that make this row's COMPLETE (or SKIP) absent.""" + blockers = list(self.complete_blocked.get(step_exec.id, [])) + if step_exec.level == 0: + seen = {i.id for i in blockers} + blockers += [ + i for i in self.op_complete_blocked.get(step_exec.id, []) if i.id not in seen + ] + return blockers + + def blockers_for_start(self, step_exec: StepExecution) -> list[Issue]: + """Boundary holds bound to this row — gate its COMPLETE (no start + moment exists to gate).""" + return list(self.start_blocked.get(step_exec.id, [])) + + +def get_hold_state(db: Session, instance_id: int) -> HoldState: + """Compute the full blocking state for a work order.""" + state = HoldState() + issues = blocking_issues_for_instance(db, instance_id) + if not issues: + return state + + steps = db.query(StepExecution).filter(StepExecution.instance_id == instance_id).all() + by_id = {se.id: se for se in steps} + op_by_number = {se.step_number: se for se in steps if se.level == 0} + + def op_row_for(step_exec: StepExecution) -> StepExecution | None: + if step_exec.level == 0: + return step_exec + if step_exec.parent_step_order is not None: + return op_by_number.get(step_exec.parent_step_order) + return None + + for issue in issues: + containment = _val(issue.containment) + anchor = by_id.get(issue.containment_step_id) or by_id.get(issue.raised_step_id) + + if containment == Containment.WO.value: + state.wo_blocked.append(issue) + continue + + if anchor is None: + # step/op containment with no resolvable step in this WO holds the + # WO itself rather than silently blocking nothing. + state.wo_blocked.append(issue) + continue + + if containment == Containment.OP.value: + op_row = op_row_for(anchor) + if op_row is not None: + state.op_complete_blocked.setdefault(op_row.id, []).append(issue) + continue + + # containment == step + bound_elsewhere = ( + issue.containment_step_id is not None + and issue.raised_step_id is not None + and issue.containment_step_id != issue.raised_step_id + ) + if bound_elsewhere: + # "resolve by 3.7": work continues up to the boundary, which + # cannot COMPLETE until disposition. + state.start_blocked.setdefault(anchor.id, []).append(issue) + else: + state.complete_blocked.setdefault(anchor.id, []).append(issue) + op_row = op_row_for(anchor) + if op_row is not None and op_row.id != anchor.id: + state.op_complete_blocked.setdefault(op_row.id, []).append(issue) + + return state + + +def _step_label(step_exec: StepExecution) -> str: + return step_exec.step_number_str or str(step_exec.step_number) + + +def _op_label(step_exec: StepExecution) -> str: + return f"OP {_step_label(step_exec)}" + + +def scope_label(step_exec: StepExecution) -> str: + """Scope name, never a bare number: 'OP 4' for op rows, '4.1' for steps.""" + return _op_label(step_exec) if step_exec.level == 0 else _step_label(step_exec) + + +def holding_readout(db: Session, issue: Issue) -> list[dict]: + """What this issue is stopping, as [{label, scope, href}] — the HOLDING + panel, the disposition confirm sentence, and MCP holds all read from here. + Labels are scope + verb ('OP 4 COMPLETE', '4.1 COMPLETE').""" + if not issue.is_blocking: + return [] + + instance_id = issue.procedure_instance_id + containment = _val(issue.containment) + + instance = ( + db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() + if instance_id + else None + ) + if instance is None: + return [] + + wo_label = instance.work_order_number or f"WO #{instance.id}" + + def exec_href(op_order: int | None = None) -> str: + base = f"/executions/{instance.id}" + return f"{base}?op={op_order}" if op_order is not None else base + + anchor = None + anchor_id = issue.containment_step_id or issue.raised_step_id + if anchor_id is not None: + anchor = db.query(StepExecution).filter(StepExecution.id == anchor_id).first() + + if containment == Containment.WO.value or anchor is None: + return [{"label": f"{wo_label} COMPLETE", "scope": wo_label, "href": exec_href()}] + + op_row = anchor + if anchor.level != 0 and anchor.parent_step_order is not None: + op_row = ( + db.query(StepExecution) + .filter( + StepExecution.instance_id == instance.id, + StepExecution.step_number == anchor.parent_step_order, + StepExecution.level == 0, + ) + .first() + ) or anchor + + if containment == Containment.OP.value: + return [ + { + "label": f"{_op_label(op_row)} COMPLETE", + "scope": _op_label(op_row), + "href": exec_href(op_row.step_number), + } + ] + + # step containment + bound_elsewhere = ( + issue.containment_step_id is not None + and issue.raised_step_id is not None + and issue.containment_step_id != issue.raised_step_id + ) + if bound_elsewhere: + return [ + { + "label": f"{scope_label(anchor)} COMPLETE", + "scope": scope_label(anchor), + "href": exec_href(op_row.step_number), + } + ] + + targets = [ + { + "label": f"{scope_label(anchor)} COMPLETE", + "scope": scope_label(anchor), + "href": exec_href(op_row.step_number), + } + ] + if op_row.id != anchor.id: + targets.append( + { + "label": f"{_op_label(op_row)} COMPLETE", + "scope": _op_label(op_row), + "href": exec_href(op_row.step_number), + } + ) + return targets + + +def get_holds_payload(db: Session, instance_id: int) -> dict: + """The controller's blocking state as JSON: every undispositioned issue on + this work order and what it blocks.""" + issues = blocking_issues_for_instance(db, instance_id) + holds = [] + for issue in issues: + holds.append( + { + "issue_id": issue.id, + "issue_number": issue.issue_number, + "title": issue.title, + "priority": _val(issue.priority), + "containment": _val(issue.containment), + "raised_step_id": issue.raised_step_id, + "containment_step_id": issue.containment_step_id, + "blocks": [t["label"] for t in holding_readout(db, issue)], + } + ) + return {"instance_id": instance_id, "held": bool(holds), "holds": holds} diff --git a/src/opal/core/lifecycle.py b/src/opal/core/lifecycle.py index 5ad1ac9..6e693b1 100644 --- a/src/opal/core/lifecycle.py +++ b/src/opal/core/lifecycle.py @@ -121,10 +121,9 @@ def enter_demo(current_user=None) -> int | None: """ global _real_upload_dir + from opal.db.models.part import Part from opal.db.models.user import User - fresh = not demo_file_exists() - # Carry the auth mode across so e.g. an exe-mode deployment doesn't # bounce its users to a local login they cannot complete. auth_mode = get_active_settings().auth_mode @@ -136,6 +135,11 @@ def enter_demo(current_user=None) -> int | None: demo_user_id: int | None = None with SessionLocal() as db: + # Freshness is a property of the CONTENT, not the file: _switch_database + # creates the file before the seed commits, so an interrupted first + # seed leaves an empty-but-existing demo database. A contentless demo + # (no users, no parts) is seeded; a seeded one never is. + fresh = db.query(User.id).first() is None and db.query(Part.id).first() is None if fresh: from opal.seed import seed_database diff --git a/src/opal/db/models/__init__.py b/src/opal/db/models/__init__.py index a9de295..0553bcf 100644 --- a/src/opal/db/models/__init__.py +++ b/src/opal/db/models/__init__.py @@ -7,7 +7,7 @@ from opal.db.models.baseline_event import BaselineEvent, BaselineEventItem from opal.db.models.dataset import DataPoint, Dataset from opal.db.models.designator import DesignatorSequence -from opal.db.models.execution import ProcedureInstance, StepExecution +from opal.db.models.execution import ProcedureInstance, StepExecution, StepFocus, StepNote from opal.db.models.genealogy import AssemblyComponent from opal.db.models.inventory import ( InventoryConsumption, @@ -28,6 +28,7 @@ ProcedureStep, ProcedureVersion, StepDependency, + StepImage, StepKit, ) from opal.db.models.purchase import Purchase, PurchaseExpense, PurchaseLine @@ -78,9 +79,12 @@ "RiskIssueLink", "RiskIssueRole", "RiskReference", + "StepFocus", "StepDependency", "StepExecution", + "StepImage", "StepKit", + "StepNote", "StockTestResult", "StockTransfer", "Supplier", diff --git a/src/opal/db/models/attachment.py b/src/opal/db/models/attachment.py index ef8647a..02bca26 100644 --- a/src/opal/db/models/attachment.py +++ b/src/opal/db/models/attachment.py @@ -1,6 +1,6 @@ """Attachment model.""" -from sqlalchemy import BigInteger, ForeignKey, String +from sqlalchemy import BigInteger, ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from opal.db.base import Base, IdMixin, TimestampMixin @@ -21,7 +21,13 @@ class Attachment(Base, IdMixin, TimestampMixin): String(20), nullable=True, index=True, - comment="'inline' = embedded in markdown content; 'reference' = downloadable doc; 'closeout' = end-item closeout photo surfaced in build reports; null = legacy/unscoped", + comment="'inline' = embedded in markdown content; 'reference' = downloadable doc; 'closeout' = end-item closeout photo surfaced in build reports; 'capture' = execution evidence captured at a step; 'step_image' = authored step reference image; null = legacy/unscoped", + ) + note: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="Capturer's note on an execution capture" + ) + uploaded_by_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True ) # Optional links - attachment can belong to instance, step, issue, procedure, or neither @@ -49,6 +55,7 @@ class Attachment(Base, IdMixin, TimestampMixin): "StepExecution", back_populates="attachments" ) issue: Mapped["Issue | None"] = relationship("Issue", back_populates="attachments") + uploaded_by: Mapped["User | None"] = relationship("User") def __repr__(self) -> str: return f"" diff --git a/src/opal/db/models/execution.py b/src/opal/db/models/execution.py index b814985..2d634e2 100644 --- a/src/opal/db/models/execution.py +++ b/src/opal/db/models/execution.py @@ -4,17 +4,17 @@ from enum import Enum from typing import Any -from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from opal.db.base import Base, IdMixin, TimestampMixin class InstanceStatus(str, Enum): - """Procedure instance status.""" + """Procedure instance (work order) status.""" - PENDING = "pending" - IN_PROGRESS = "in_progress" + CUT = "cut" # Untouched since cut from the master procedure + IN_WORK = "in_work" COMPLETED = "completed" ABORTED = "aborted" @@ -47,7 +47,7 @@ class ProcedureInstance(Base, IdMixin, TimestampMixin): String(100), nullable=True, index=True, comment="For grouping related instances" ) status: Mapped[InstanceStatus] = mapped_column( - String(20), nullable=False, default=InstanceStatus.PENDING + String(20), nullable=False, default=InstanceStatus.CUT ) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) @@ -71,15 +71,6 @@ class ProcedureInstance(Base, IdMixin, TimestampMixin): default=0, comment="Higher = more urgent (0=normal, 1=high, 2=urgent)", ) - target_entity: Mapped[dict[str, Any] | None] = mapped_column( - JSON, - nullable=True, - comment=( - "Entity this execution is run against, " - "e.g. {entity_type: 'part', entity_id: 42, entity_label: 'SN-00042'}" - ), - ) - # Relationships procedure: Mapped["MasterProcedure"] = relationship( "MasterProcedure", back_populates="instances" @@ -134,6 +125,11 @@ class StepExecution(Base, IdMixin, TimestampMixin): JSON, nullable=True, comment="Values matching step's required_data_schema" ) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + first_focused_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="Soft telemetry: first cursor focus; never rendered as state", + ) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) completed_by_id: Mapped[int | None] = mapped_column( ForeignKey("user.id", ondelete="SET NULL"), nullable=True @@ -156,11 +152,6 @@ class StepExecution(Base, IdMixin, TimestampMixin): Integer, nullable=True, comment="Order of parent step (for sub-steps)" ) - # Operator notes - notes: Mapped[str | None] = mapped_column( - Text, nullable=True, comment="Free-text operator notes" - ) - # Sign-off fields (for parent OPs or steps with requires_signoff) signed_off_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, comment="When OP was signed off" @@ -215,6 +206,15 @@ class StepExecution(Base, IdMixin, TimestampMixin): consumptions: Mapped[list["InventoryConsumption"]] = relationship( "InventoryConsumption", back_populates="step_execution" ) + focuses: Mapped[list["StepFocus"]] = relationship( + "StepFocus", back_populates="step_execution", cascade="all, delete-orphan" + ) + notes: Mapped[list["StepNote"]] = relationship( + "StepNote", + back_populates="step_execution", + cascade="all, delete-orphan", + order_by="StepNote.created_at, StepNote.id", + ) @property def duration_seconds(self) -> int | None: @@ -225,3 +225,64 @@ def duration_seconds(self) -> int | None: def __repr__(self) -> str: return f"" + + +class StepNote(Base, IdMixin, TimestampMixin): + """Timestamped, authored, append-only operator note on a step execution. + + A note is a record, not a control: it attaches to any step in any status, + on any work order — the debugging value is the timestamp trail. Append-only + (like IssueComment): no edit, no delete path. + """ + + step_execution_id: Mapped[int] = mapped_column( + ForeignKey("step_execution.id", ondelete="CASCADE"), nullable=False, index=True + ) + author_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True + ) + body: Mapped[str] = mapped_column(Text, nullable=False) + + # Relationships + step_execution: Mapped["StepExecution"] = relationship( + "StepExecution", back_populates="notes" + ) + author: Mapped["User | None"] = relationship("User") + + def __repr__(self) -> str: + return f"" + + +class StepFocus(Base, IdMixin, TimestampMixin): + """A user's cursor position in the execution document. + + Presence = focus: one row per (instance, user), upserted as the cursor + moves. Moving the cursor records no event — this is ephemeral presence, + not history. Several users may focus the same step. + """ + + __table_args__ = (UniqueConstraint("instance_id", "user_id", name="uq_step_focus_user"),) + + instance_id: Mapped[int] = mapped_column( + ForeignKey("procedure_instance.id", ondelete="CASCADE"), nullable=False, index=True + ) + step_execution_id: Mapped[int] = mapped_column( + ForeignKey("step_execution.id", ondelete="CASCADE"), nullable=False, index=True + ) + user_id: Mapped[int] = mapped_column( + ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True + ) + focused_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + # Relationships + instance: Mapped["ProcedureInstance"] = relationship("ProcedureInstance") + step_execution: Mapped["StepExecution"] = relationship( + "StepExecution", back_populates="focuses" + ) + user: Mapped["User"] = relationship("User") + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/src/opal/db/models/issue.py b/src/opal/db/models/issue.py index 0b2ced3..950e2c2 100644 --- a/src/opal/db/models/issue.py +++ b/src/opal/db/models/issue.py @@ -1,8 +1,20 @@ -"""Issue model.""" +"""Issue model. +Issues are the authorized container for off-nominal reality (AS9100 §8.7 +lineage): when reality departs from the published procedure, work either +stops at the containment boundary or continues inside a ticket under a +signed disposition. Two gates, not one: + + raised → UNDISPOSITIONED → (disposition signed) → DISPOSITIONED → CLOSED + +The blocking predicate is `undispositioned`, never `open`. A signed +disposition releases work immediately; closure is bookkeeping. +""" + +from datetime import datetime from enum import Enum -from sqlalchemy import ForeignKey, String, Text +from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from opal.db.base import Base, IdMixin, SoftDeleteMixin, TimestampMixin @@ -18,12 +30,10 @@ class IssueType(str, Enum): class IssueStatus(str, Enum): - """Issue status.""" + """Issue status — open or closed; the disposition gate is a separate, + derived state (see Issue.disp_state).""" OPEN = "open" - INVESTIGATING = "investigating" - DISPOSITION_PENDING = "disposition_pending" - DISPOSITION_APPROVED = "disposition_approved" CLOSED = "closed" @@ -37,13 +47,33 @@ class IssuePriority(str, Enum): class DispositionType(str, Enum): - """Disposition type for issue resolution.""" + """Disposition type (MIL-STD-1520 lineage).""" USE_AS_IS = "use_as_is" REWORK = "rework" + REPAIR = "repair" SCRAP = "scrap" - RETURN_TO_VENDOR = "return_to_vendor" - OTHER = "other" + RETURN_TO_SUPPLIER = "return_to_supplier" + NO_DEFECT = "no_defect" + + +class Containment(str, Enum): + """What an undispositioned issue holds.""" + + STEP = "step" + OP = "op" + WO = "wo" + ADVISORY = "advisory" + + +# Widening moves up this ladder freely; narrowing releases a hold and is +# therefore audited with a note. +CONTAINMENT_RANK: dict[str, int] = { + Containment.ADVISORY.value: 0, + Containment.STEP.value: 1, + Containment.OP.value: 2, + Containment.WO.value: 3, +} class Issue(Base, IdMixin, TimestampMixin, SoftDeleteMixin): @@ -72,13 +102,19 @@ class Issue(Base, IdMixin, TimestampMixin, SoftDeleteMixin): String(20), nullable=False, default=IssuePriority.MEDIUM ) - # Type-specific fields - should_be: Mapped[str | None] = mapped_column( - Text, nullable=True, comment="NC: expected condition" + # Containment: what this issue holds while undispositioned + containment: Mapped[Containment] = mapped_column( + String(20), nullable=False, default=Containment.ADVISORY ) - is_condition: Mapped[str | None] = mapped_column( - Text, nullable=True, comment="NC: actual condition" + containment_step_id: Mapped[int | None] = mapped_column( + ForeignKey("step_execution.id", ondelete="SET NULL"), + nullable=True, + comment="Boundary step ('resolve by 3.7'); for op/wo containment the boundary is implicit", ) + + # Capture: the should-be / is pair belongs at the moment of discovery + should_be: Mapped[str | None] = mapped_column(Text, nullable=True, comment="Expected condition") + actual: Mapped[str | None] = mapped_column(Text, nullable=True, comment="Actual condition") steps_to_reproduce: Mapped[str | None] = mapped_column( Text, nullable=True, comment="Bug: repro steps" ) @@ -90,11 +126,15 @@ class Issue(Base, IdMixin, TimestampMixin, SoftDeleteMixin): Text, nullable=True, comment="Improvement: benefit" ) - # Disposition fields + # Disposition — the signature releases containment; root cause and + # corrective action are required at NC closure, not at disposition. root_cause: Mapped[str | None] = mapped_column(Text, nullable=True) corrective_action: Mapped[str | None] = mapped_column(Text, nullable=True) disposition_type: Mapped[DispositionType | None] = mapped_column(String(30), nullable=True) - disposition_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + disposition_rationale: Mapped[str | None] = mapped_column(Text, nullable=True) + dispositioned_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) # Optional links - an issue can link to any of these (or none) part_id: Mapped[int | None] = mapped_column( @@ -106,16 +146,19 @@ class Issue(Base, IdMixin, TimestampMixin, SoftDeleteMixin): procedure_instance_id: Mapped[int | None] = mapped_column( ForeignKey("procedure_instance.id", ondelete="SET NULL"), nullable=True, index=True ) - step_execution_id: Mapped[int | None] = mapped_column( + raised_step_id: Mapped[int | None] = mapped_column( ForeignKey("step_execution.id", ondelete="SET NULL"), nullable=True, index=True, - comment="For NC issues created during step execution", + comment="Where it was found (execution-raised issues)", + ) + raised_by_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True ) assigned_to_id: Mapped[int | None] = mapped_column( ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True ) - disposition_approved_by_id: Mapped[int | None] = mapped_column( + dispositioned_by_id: Mapped[int | None] = mapped_column( ForeignKey("user.id", ondelete="SET NULL"), nullable=True ) @@ -127,15 +170,22 @@ class Issue(Base, IdMixin, TimestampMixin, SoftDeleteMixin): procedure_instance: Mapped["ProcedureInstance | None"] = relationship( "ProcedureInstance", back_populates="issues" ) + raised_step: Mapped["StepExecution | None"] = relationship( + "StepExecution", foreign_keys=[raised_step_id] + ) + containment_step: Mapped["StepExecution | None"] = relationship( + "StepExecution", foreign_keys=[containment_step_id] + ) risk_links: Mapped[list["RiskIssueLink"]] = relationship( "RiskIssueLink", back_populates="issue", cascade="all, delete-orphan" ) references: Mapped[list["IssueReference"]] = relationship( "IssueReference", back_populates="issue", cascade="all, delete-orphan" ) + raised_by: Mapped["User | None"] = relationship("User", foreign_keys=[raised_by_id]) assigned_to: Mapped["User | None"] = relationship("User", foreign_keys=[assigned_to_id]) - disposition_approved_by: Mapped["User | None"] = relationship( - "User", foreign_keys=[disposition_approved_by_id] + dispositioned_by: Mapped["User | None"] = relationship( + "User", foreign_keys=[dispositioned_by_id] ) comments: Mapped[list["IssueComment"]] = relationship( "IssueComment", @@ -145,5 +195,49 @@ class Issue(Base, IdMixin, TimestampMixin, SoftDeleteMixin): ) attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="issue") + @property + def dispositioned(self) -> bool: + """Derived: disposition type and signature both set.""" + return self.disposition_type is not None and self.dispositioned_at is not None + + @property + def containment_bearing(self) -> bool: + """Disposition state exists only above advisory containment.""" + containment = ( + self.containment.value if hasattr(self.containment, "value") else self.containment + ) + return containment != Containment.ADVISORY.value + + @property + def disp_state(self) -> str: + """Containment-bearing: undispositioned | dispositioned | closed. + Advisory issues carry plain open | closed.""" + status = self.status.value if hasattr(self.status, "value") else self.status + if status == IssueStatus.CLOSED.value: + return "closed" + if not self.containment_bearing: + return status + return "dispositioned" if self.dispositioned else "undispositioned" + + @property + def is_blocking(self) -> bool: + """True when this issue currently holds work: undispositioned with + non-advisory containment.""" + return ( + self.deleted_at is None + and self.containment_bearing + and self.disp_state == "undispositioned" + ) + + @property + def self_dispositioned(self) -> bool: + """Signer == raiser. Visible, not forbidden — a two-person shop cannot + always separate roles; the record can always say so.""" + return ( + self.raised_by_id is not None + and self.dispositioned_by_id is not None + and self.raised_by_id == self.dispositioned_by_id + ) + def __repr__(self) -> str: return f"" diff --git a/src/opal/db/models/part.py b/src/opal/db/models/part.py index 41623a4..42eb689 100644 --- a/src/opal/db/models/part.py +++ b/src/opal/db/models/part.py @@ -21,6 +21,19 @@ class TrackingType(str, Enum): SERIALIZED = "serialized" # Each unit gets its own OPAL number +class ProcurementType(str, Enum): + """How this part comes to exist — declares which sections expect content. + + make = built in-house (a BOM is expected); buy = purchased (suppliers + and PO lines are expected); both = made and bought. The declaration + governs expected emptiness only — actual data always renders. + """ + + MAKE = "make" + BUY = "buy" + BOTH = "both" + + class Part(Base, IdMixin, TimestampMixin, SoftDeleteMixin): """Part in the inventory system. @@ -52,6 +65,14 @@ class Part(Base, IdMixin, TimestampMixin, SoftDeleteMixin): default=TrackingType.SERIALIZED, comment="bulk = one OPAL per batch, serialized = one OPAL per unit (default)", ) + procurement: Mapped[ProcurementType] = mapped_column( + String(10), + nullable=False, + default=ProcurementType.MAKE, + server_default="make", + index=True, + comment="make = in-house (BOM expected), buy = purchased (suppliers/POs expected), both", + ) # Tiered inventory classification; levels and their semantics are # project-configured (TierConfig in opal.project) diff --git a/src/opal/db/models/procedure.py b/src/opal/db/models/procedure.py index 30789ea..f98a236 100644 --- a/src/opal/db/models/procedure.py +++ b/src/opal/db/models/procedure.py @@ -119,6 +119,11 @@ class ProcedureStep(Base, IdMixin, TimestampMixin): nullable=True, comment="Safety warning displayed prominently during editing and execution", ) + strict_sequence: Mapped[bool] = mapped_column( + default=False, + nullable=False, + comment="OP-level: sub-steps must start in order (N requires N-1 terminal)", + ) workcenter_id: Mapped[int | None] = mapped_column( ForeignKey("workcenter.id", ondelete="SET NULL"), nullable=True, @@ -140,11 +145,42 @@ class ProcedureStep(Base, IdMixin, TimestampMixin): step_kits: Mapped[list["StepKit"]] = relationship( "StepKit", back_populates="step", cascade="all, delete-orphan" ) + images: Mapped[list["StepImage"]] = relationship( + "StepImage", + back_populates="step", + cascade="all, delete-orphan", + order_by="StepImage.position", + ) def __repr__(self) -> str: return f"" +class StepImage(Base, IdMixin, TimestampMixin): + """Authored reference image on a step — procedure content, not evidence. + + Snapshotted into ProcedureVersion.content at publish (by attachment id); + the attachment row/file must therefore outlive this link row — deleting a + StepImage only unlinks it from the master step. + """ + + step_id: Mapped[int] = mapped_column( + ForeignKey("procedure_step.id", ondelete="CASCADE"), nullable=False, index=True + ) + attachment_id: Mapped[int] = mapped_column( + ForeignKey("attachment.id", ondelete="RESTRICT"), nullable=False, index=True + ) + caption: Mapped[str | None] = mapped_column(String(255), nullable=True) + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + # Relationships + step: Mapped["ProcedureStep"] = relationship("ProcedureStep", back_populates="images") + attachment: Mapped["Attachment"] = relationship("Attachment") + + def __repr__(self) -> str: + return f"" + + class ProcedureVersion(Base, IdMixin, TimestampMixin): """Immutable snapshot of a procedure at publish time.""" diff --git a/src/opal/integrations/onshape/sync.py b/src/opal/integrations/onshape/sync.py index efba168..b29fe8f 100644 --- a/src/opal/integrations/onshape/sync.py +++ b/src/opal/integrations/onshape/sync.py @@ -485,6 +485,9 @@ def pull_sync( continue internal_pn = next_part_number(db, default_tier) + # procurement stays at its default (make): external_pn here is + # the CAD part number, not a vendor PN — the buy-when-external + # creation heuristic does not apply to CAD-synced geometry part = Part( name=item.part_name, description=item.description or None, diff --git a/src/opal/mcp/server.py b/src/opal/mcp/server.py index a42728d..c8f4040 100644 --- a/src/opal/mcp/server.py +++ b/src/opal/mcp/server.py @@ -12,6 +12,7 @@ from mcp.types import TextContent, Tool from sqlalchemy import func +from opal.api.routes.issues import _recheck_instance_completion from opal.config import get_active_project, get_active_settings from opal.core.audit import get_model_dict, log_create, log_delete, log_update from opal.core.designators import ( @@ -20,6 +21,14 @@ generate_requirement_number, generate_risk_number, ) +from opal.core.execution_flow import ( + FlowError, + build_execution_state, + complete_step_flow, + focus_step, +) +from opal.core.execution_flow import add_step_note as flow_add_step_note +from opal.core.holds import get_holds_payload, holding_readout from opal.core.numbering import ( PartNumberError, next_part_number, @@ -56,8 +65,15 @@ User, Workcenter, ) -from opal.db.models.issue import IssuePriority, IssueStatus, IssueType -from opal.db.models.part import TrackingType +from opal.db.models.execution import ProcedureInstance, StepExecution +from opal.db.models.issue import ( + Containment, + DispositionType, + IssuePriority, + IssueStatus, + IssueType, +) +from opal.db.models.part import ProcurementType, TrackingType from opal.db.models.procedure import ProcedureStatus, ProcedureType, UsageType from opal.db.models.purchase import PurchaseStatus from opal.db.models.risk import RiskDisposition, RiskIssueLink, RiskIssueRole @@ -167,6 +183,15 @@ async def list_tools() -> list[Tool]: "type": "string", "description": "External/manufacturer part number (optional)", }, + "procurement": { + "type": "string", + "enum": ["make", "buy", "both"], + "description": ( + "How the part comes to exist; governs which part-page " + "sections expect content. Default: buy when external_pn " + "is given, else make" + ), + }, "unit_of_measure": { "type": "string", "description": "Unit of measure (default: each)", @@ -335,6 +360,11 @@ async def list_tools() -> list[Tool]: "type": "string", "description": "Safety warning text displayed in red during execution (optional)", }, + "strict_sequence": { + "type": "boolean", + "default": False, + "description": "OP-level: sub-steps must start in order", + }, "required_data_schema": { "type": "object", "description": ( @@ -411,6 +441,7 @@ async def list_tools() -> list[Tool]: "estimated_duration_minutes": {"type": "integer", "minimum": 1}, "required_role": {"type": "string"}, "caution": {"type": "string"}, + "strict_sequence": {"type": "boolean"}, "required_data_schema": {"type": "object"}, }, "required": ["procedure_id", "step_id"], @@ -717,13 +748,21 @@ async def list_tools() -> list[Tool]: # Issues Tool( name="list_issues", - description="List issues, optionally filtered by status or type", + description="List issues, optionally filtered by status, disposition state, or type", inputSchema={ "type": "object", "properties": { "status": { "type": "string", - "description": "Filter by status: open, investigating, disposition_pending, disposition_approved, closed", + "description": "Filter by status: open, closed", + }, + "disp_state": { + "type": "string", + "enum": ["open", "undispositioned", "dispositioned", "closed"], + "description": ( + "Filter by state: open (advisory-open), undispositioned, " + "dispositioned (containment-bearing only), closed" + ), }, "issue_type": { "type": "string", @@ -738,8 +777,13 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="create_issue", - description="Create a new issue to track a problem, task, or improvement", + name="raise_issue", + description=( + "Raise an issue (problem, task, improvement, or non-conformance). " + "Containment declares what the issue holds while undispositioned: " + "step | op | wo | advisory (default advisory; execution-raised " + "issues should use step and set raised_step_id)." + ), inputSchema={ "type": "object", "properties": { @@ -761,10 +805,110 @@ async def list_tools() -> list[Tool]: "description": "Priority: low, medium, high, critical (default: medium)", "default": "medium", }, + "containment": { + "type": "string", + "description": "What this issue holds while undispositioned: step, op, wo, advisory (default: advisory)", + "default": "advisory", + }, + "should_be": { + "type": "string", + "description": "Expected condition (the NC pair, captured at discovery)", + }, + "actual": { + "type": "string", + "description": "Actual condition", + }, + "procedure_instance_id": { + "type": "integer", + "description": "Work order this issue belongs to (required for non-advisory containment)", + }, + "raised_step_id": { + "type": "integer", + "description": "Step execution ID where it was found", + }, + "containment_step_id": { + "type": "integer", + "description": "Boundary step execution ID ('resolve by 3.7'); blocks that step's START", + }, + "part_id": {"type": "integer"}, + "assigned_to_id": {"type": "integer"}, + "user_id": { + "type": "integer", + "description": "Human user raising the issue", + }, }, "required": ["title"], }, ), + Tool( + name="sign_disposition", + description=( + "Sign an issue's disposition — the signature releases every " + "containment the issue holds, immediately. user_id must be the " + "human making the decision." + ), + inputSchema={ + "type": "object", + "properties": { + "issue_id": {"type": "integer"}, + "user_id": { + "type": "integer", + "description": "Human user signing the disposition", + }, + "disposition_type": { + "type": "string", + "description": "use_as_is, rework, repair, scrap, return_to_supplier, no_defect", + }, + "rationale": { + "type": "string", + "description": "Disposition rationale", + }, + }, + "required": ["issue_id", "user_id", "disposition_type", "rationale"], + }, + ), + Tool( + name="set_containment", + description=( + "Change an issue's containment scope. Narrowing (e.g. op → step, " + "or anything → advisory) releases a hold and requires a note, " + "which is recorded on the issue." + ), + inputSchema={ + "type": "object", + "properties": { + "issue_id": {"type": "integer"}, + "containment": { + "type": "string", + "description": "step, op, wo, advisory", + }, + "containment_step_id": {"type": "integer"}, + "note": { + "type": "string", + "description": "Required when narrowing scope", + }, + "user_id": {"type": "integer"}, + }, + "required": ["issue_id", "containment"], + }, + ), + Tool( + name="get_holds", + description=( + "Blocking state for a work order: every undispositioned issue " + "holding it and what each blocks (are we held and why)." + ), + inputSchema={ + "type": "object", + "properties": { + "execution_id": { + "type": "integer", + "description": "Procedure instance (work order) ID", + }, + }, + "required": ["execution_id"], + }, + ), # Risks Tool( name="list_risks", @@ -1683,6 +1827,13 @@ async def list_tools() -> list[Tool]: "unit_of_measure": {"type": "string", "default": "each"}, "description": {"type": "string"}, "external_pn": {"type": "string"}, + "procurement": { + "type": "string", + "enum": ["make", "buy", "both"], + "description": ( + "Default: buy when external_pn is given, else make" + ), + }, "reorder_point": {"type": "number"}, "parent_id": {"type": "integer"}, }, @@ -1810,6 +1961,175 @@ async def list_tools() -> list[Tool]: "required": ["name"], }, ), + # Execution document (multiplayer work orders) + Tool( + name="get_execution_state", + description=( + "Full document state of a running work order: every step with " + "status/cursors/holds/evidence counts, the presence roster, and " + "active hold points — the controller's view as JSON. Poll this " + "to observe a live test." + ), + inputSchema={ + "type": "object", + "properties": { + "execution_id": {"type": "integer"}, + "work_order": { + "type": "string", + "description": "Alternative lookup, e.g. 'WO-00002'", + }, + }, + }, + ), + Tool( + name="join_execution", + description=( + "Join a work order as an observer participant (appears in the " + "presence roster before placing a cursor)." + ), + inputSchema={ + "type": "object", + "properties": { + "execution_id": {"type": "integer"}, + "work_order": {"type": "string"}, + "user_id": { + "type": "integer", + "description": "Active user joining the execution", + }, + }, + "required": ["user_id"], + }, + ), + Tool( + name="focus_step", + description=( + "Move a user's cursor to a step. Presence = focus: the cursor " + "is broadcast to everyone in the document, several users may " + "focus the same step, and moving records no event." + ), + inputSchema={ + "type": "object", + "properties": { + "execution_id": {"type": "integer"}, + "work_order": {"type": "string"}, + "step_number": { + "type": "integer", + "description": "Snapshot step order (the 'order' field in execution state)", + }, + "user_id": { + "type": "integer", + "description": "ID of the human user at the step", + }, + }, + "required": ["step_number", "user_id"], + }, + ), + Tool( + name="complete_step", + description=( + "Complete a step with optional captured data. Requires the " + "user_id of the human who performed the work — agents observe, " + "attach, and file anomalies, but completion carries a human " + "signature." + ), + inputSchema={ + "type": "object", + "properties": { + "execution_id": {"type": "integer"}, + "work_order": {"type": "string"}, + "step_number": {"type": "integer"}, + "user_id": { + "type": "integer", + "description": "ID of the human user who performed the step", + }, + "data": { + "type": "object", + "description": "Captured values matching the step's data schema", + }, + "notes": {"type": "string"}, + }, + "required": ["step_number", "user_id"], + }, + ), + Tool( + name="attach_to_step", + description=( + "Attach evidence to a step of a running work order (execution " + "capture). Provide a server-readable file_path, or content " + "(text) with a filename." + ), + inputSchema={ + "type": "object", + "properties": { + "execution_id": {"type": "integer"}, + "work_order": {"type": "string"}, + "step_number": {"type": "integer"}, + "file_path": { + "type": "string", + "description": "Absolute path of a file to ingest", + }, + "content": { + "type": "string", + "description": "Inline text content to store as a file", + }, + "filename": { + "type": "string", + "description": "Filename when using content", + }, + "note": {"type": "string", "description": "Capturer's note"}, + "user_id": {"type": "integer", "description": "Capturing user, if any"}, + }, + "required": ["step_number"], + }, + ), + Tool( + name="add_step_note", + description=( + "Append a timestamped, authored note to a step. Notes are a " + "record, not a control: any step status, any work order status." + ), + inputSchema={ + "type": "object", + "properties": { + "execution_id": {"type": "integer"}, + "work_order": {"type": "string"}, + "step_number": {"type": "integer"}, + "note": {"type": "string"}, + "user_id": { + "type": "integer", + "description": "Operator appending the note (optional, for attribution)", + }, + }, + "required": ["step_number", "note"], + }, + ), + Tool( + name="bind_issue_hold", + description=( + "Bind an issue's containment boundary to a step ('resolve by'): " + "that step cannot COMPLETE while the issue is undispositioned. " + "One boundary per issue; re-binding moves it. The hold lifts the " + "moment the disposition is signed." + ), + inputSchema={ + "type": "object", + "properties": { + "issue_id": {"type": "integer"}, + "execution_id": {"type": "integer"}, + "work_order": {"type": "string"}, + "step_number": {"type": "integer"}, + "user_id": { + "type": "integer", + "description": "Human user binding the hold (containment is a gate change)", + }, + "note": { + "type": "string", + "description": "Required when the bind narrows a blocking issue's containment", + }, + }, + "required": ["issue_id", "step_number", "user_id"], + }, + ), ] @@ -1887,8 +2207,14 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: # Issues elif name == "list_issues": return await _list_issues(db, arguments) - elif name == "create_issue": - return await _create_issue(db, arguments) + elif name in ("raise_issue", "create_issue"): + return await _raise_issue(db, arguments) + elif name == "sign_disposition": + return await _sign_disposition(db, arguments) + elif name == "set_containment": + return await _set_containment(db, arguments) + elif name == "get_holds": + return await _get_holds(db, arguments) # Risks elif name == "list_risks": @@ -1988,6 +2314,22 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: elif name == "build_procedure": return await _build_procedure(db, arguments) + # Execution document + elif name == "get_execution_state": + return await _get_execution_state(db, arguments) + elif name == "join_execution": + return await _join_execution(db, arguments) + elif name == "focus_step": + return await _focus_step(db, arguments) + elif name == "complete_step": + return await _complete_step(db, arguments) + elif name == "attach_to_step": + return await _attach_to_step(db, arguments) + elif name == "add_step_note": + return await _add_step_note(db, arguments) + elif name == "bind_issue_hold": + return await _bind_issue_hold(db, arguments) + else: return json_response({"error": f"Unknown tool: {name}"}) finally: @@ -2085,6 +2427,9 @@ async def _get_part(db, args: dict) -> list[TextContent]: "tier_name": tier_name, "parent_id": part.parent_id, "unit_of_measure": part.unit_of_measure, + "procurement": part.procurement.value + if hasattr(part.procurement, "value") + else part.procurement, "lifecycle_state": part.lifecycle_state, "activated_at": part.activated_at.isoformat() if part.activated_at else None, "activation_cause": part.activation_cause, @@ -2152,6 +2497,9 @@ def _build_part(db, args: dict) -> Part: reorder_point=Decimal(str(args["reorder_point"])) if args.get("reorder_point") is not None else None, + procurement=ProcurementType(args["procurement"]).value + if args.get("procurement") + else ("buy" if args.get("external_pn") else "make"), **({"tracking_type": tracking_type} if tracking_type is not None else {}), ) @@ -2188,6 +2536,9 @@ async def _create_part(db, args: dict) -> list[TextContent]: "tier": tier, "tier_name": _tier_name(tier), "parent_id": parent_id, + "procurement": part.procurement.value + if hasattr(part.procurement, "value") + else part.procurement, "lifecycle_state": part.lifecycle_state, }, } @@ -2451,6 +2802,7 @@ async def _add_procedure_step(db, args: dict) -> list[TextContent]: estimated_duration_minutes=args.get("estimated_duration_minutes"), required_role=args.get("required_role"), caution=args.get("caution"), + strict_sequence=args.get("strict_sequence", False), ) db.add(step) db.flush() @@ -2486,6 +2838,23 @@ async def _add_procedure_step(db, args: dict) -> list[TextContent]: ) +def _issue_summary(db, issue: Issue) -> dict: + """Issue facts shared by the MCP issue tools.""" + return { + "id": issue.id, + "issue_number": issue.issue_number, + "title": issue.title, + "type": issue.issue_type.value if hasattr(issue.issue_type, "value") else issue.issue_type, + "status": issue.status.value if hasattr(issue.status, "value") else issue.status, + "disp_state": issue.disp_state, + "priority": issue.priority.value if hasattr(issue.priority, "value") else issue.priority, + "containment": issue.containment.value + if hasattr(issue.containment, "value") + else issue.containment, + "holding": [t["label"] for t in holding_readout(db, issue)], + } + + async def _list_issues(db, args: dict) -> list[TextContent]: """List issues with optional filtering.""" query = db.query(Issue).filter(Issue.deleted_at.is_(None)) @@ -2493,6 +2862,25 @@ async def _list_issues(db, args: dict) -> list[TextContent]: if args.get("status"): query = query.filter(Issue.status == args["status"]) + if args.get("disp_state"): + # Four-value STATE filter, mirroring the API: open = advisory-open; + # undispositioned / dispositioned = containment-bearing only. + signed = (Issue.disposition_type.isnot(None)) & (Issue.dispositioned_at.isnot(None)) + bearing = Issue.containment != Containment.ADVISORY + disp_state = args["disp_state"] + if disp_state == "closed": + query = query.filter(Issue.status == IssueStatus.CLOSED) + elif disp_state == "open": + query = query.filter( + Issue.status != IssueStatus.CLOSED, Issue.containment == Containment.ADVISORY + ) + elif disp_state == "dispositioned": + query = query.filter(Issue.status != IssueStatus.CLOSED, bearing, signed) + elif disp_state == "undispositioned": + query = query.filter(Issue.status != IssueStatus.CLOSED, bearing, ~signed) + else: + return json_response({"success": False, "error": f"Invalid disp_state: {disp_state}"}) + if args.get("issue_type"): query = query.filter(Issue.issue_type == args["issue_type"]) @@ -2505,9 +2893,14 @@ async def _list_issues(db, args: dict) -> list[TextContent]: "issues": [ { "id": i.id, + "issue_number": i.issue_number, "title": i.title, "type": i.issue_type.value if hasattr(i.issue_type, "value") else i.issue_type, "status": i.status.value if hasattr(i.status, "value") else i.status, + "disp_state": i.disp_state, + "containment": i.containment.value + if hasattr(i.containment, "value") + else i.containment, "priority": i.priority.value if hasattr(i.priority, "value") else i.priority, } for i in issues @@ -2516,10 +2909,34 @@ async def _list_issues(db, args: dict) -> list[TextContent]: ) -async def _create_issue(db, args: dict) -> list[TextContent]: - """Create a new issue.""" +async def _raise_issue(db, args: dict) -> list[TextContent]: + """Raise a new issue with the capture fields.""" issue_type = args.get("issue_type", "task") priority = args.get("priority", "medium") + containment = args.get("containment", "advisory") + + # A non-advisory containment holds a specific work order (core/holds filters + # on procedure_instance_id). Derive it from any step anchor, reject anchors + # naming a different WO — otherwise the hold blocks nothing or the wrong WO + # (F3). A bare draft (no anchor, no WO) is still allowed. + procedure_instance_id = args.get("procedure_instance_id") + if containment != Containment.ADVISORY.value: + for step_id in (args.get("containment_step_id"), args.get("raised_step_id")): + if step_id is None: + continue + step = db.query(StepExecution).filter(StepExecution.id == step_id).first() + if step is None: + return json_response({"success": False, "error": f"Step {step_id} not found"}) + if procedure_instance_id is None: + procedure_instance_id = step.instance_id + elif step.instance_id != procedure_instance_id: + return json_response( + { + "success": False, + "error": f"Step {step_id} belongs to a different work order " + "than procedure_instance_id", + } + ) issue = Issue( issue_number=generate_issue_number(db), @@ -2528,27 +2945,161 @@ async def _create_issue(db, args: dict) -> list[TextContent]: issue_type=IssueType(issue_type), status=IssueStatus.OPEN, priority=IssuePriority(priority), + containment=Containment(containment), + containment_step_id=args.get("containment_step_id"), + should_be=args.get("should_be"), + actual=args.get("actual"), + procedure_instance_id=procedure_instance_id, + raised_step_id=args.get("raised_step_id"), + raised_by_id=args.get("user_id"), + part_id=args.get("part_id"), + assigned_to_id=args.get("assigned_to_id"), ) db.add(issue) db.flush() - log_create(db, issue) + log_create(db, issue, args.get("user_id")) db.commit() db.refresh(issue) return json_response( { "success": True, - "message": f"Created issue '{issue.title}' with ID {issue.id}", - "issue": { - "id": issue.id, - "title": issue.title, - "type": issue_type, - "priority": priority, - }, + "message": f"Raised {issue.issue_number} '{issue.title}'", + "issue": _issue_summary(db, issue), } ) +async def _sign_disposition(db, args: dict) -> list[TextContent]: + """Sign a disposition — releases every containment the issue holds.""" + issue = db.query(Issue).filter(Issue.id == args["issue_id"], Issue.deleted_at.is_(None)).first() + if not issue: + return json_response({"success": False, "error": "Issue not found"}) + + status = issue.status.value if hasattr(issue.status, "value") else issue.status + if status == IssueStatus.CLOSED.value: + return json_response({"success": False, "error": "Issue is closed"}) + if issue.dispositioned: + return json_response({"success": False, "error": "Disposition already signed"}) + if not issue.containment_bearing: + return json_response( + { + "success": False, + "error": f"{issue.issue_number} is advisory; there is no disposition to sign", + } + ) + + try: + disposition_type = DispositionType(args["disposition_type"]) + except ValueError: + return json_response( + {"success": False, "error": f"Invalid disposition type: {args['disposition_type']}"} + ) + + released = [t["label"] for t in holding_readout(db, issue)] + + old_values = get_model_dict(issue) + issue.disposition_type = disposition_type + issue.disposition_rationale = args["rationale"] + issue.dispositioned_by_id = args["user_id"] + issue.dispositioned_at = datetime.now(UTC) + log_update(db, issue, old_values, args["user_id"]) + db.flush() + _recheck_instance_completion(db, issue) + db.commit() + db.refresh(issue) + + return json_response( + { + "success": True, + "message": f"Disposition {disposition_type.value} signed on {issue.issue_number}", + "released": released, + "self_dispositioned": issue.self_dispositioned, + "issue": _issue_summary(db, issue), + } + ) + + +async def _set_containment(db, args: dict) -> list[TextContent]: + """Change containment scope; narrowing requires a note (audited).""" + from opal.db.models.issue import CONTAINMENT_RANK + from opal.db.models.issue_comment import IssueComment + + issue = db.query(Issue).filter(Issue.id == args["issue_id"], Issue.deleted_at.is_(None)).first() + if not issue: + return json_response({"success": False, "error": "Issue not found"}) + + try: + new_containment = Containment(args["containment"]) + except ValueError: + return json_response( + {"success": False, "error": f"Invalid containment: {args['containment']}"} + ) + + old_containment = ( + issue.containment.value if hasattr(issue.containment, "value") else issue.containment + ) + narrowing = CONTAINMENT_RANK[new_containment.value] < CONTAINMENT_RANK[old_containment] + note = (args.get("note") or "").strip() + if narrowing and issue.is_blocking and not note: + return json_response( + { + "success": False, + "error": f"Narrowing containment {old_containment} → {new_containment.value} " + "releases a hold; a note is required", + } + ) + + user_id = args.get("user_id") + old_values = get_model_dict(issue) + issue.containment = new_containment + # Explicit presence, not None-skip: an omitted containment_step_id leaves + # the boundary untouched; an explicit null clears it (F8). A non-null + # boundary must belong to this issue's work order. + if "containment_step_id" in args: + new_step_id = args["containment_step_id"] + if new_step_id is not None: + step = db.query(StepExecution).filter(StepExecution.id == new_step_id).first() + if step is None: + return json_response({"success": False, "error": f"Step {new_step_id} not found"}) + if issue.procedure_instance_id is None: + issue.procedure_instance_id = step.instance_id + elif step.instance_id != issue.procedure_instance_id: + return json_response( + { + "success": False, + "error": f"Step {new_step_id} belongs to a different work order " + "than this issue", + } + ) + issue.containment_step_id = new_step_id + log_update(db, issue, old_values, user_id) + + if narrowing and note: + comment = IssueComment( + issue_id=issue.id, + user_id=user_id, + body=f"Containment narrowed {old_containment} → {new_containment.value}: {note}", + ) + db.add(comment) + db.flush() + log_create(db, comment, user_id) + + if narrowing: + db.flush() + _recheck_instance_completion(db, issue) + + db.commit() + db.refresh(issue) + + return json_response({"success": True, "issue": _issue_summary(db, issue)}) + + +async def _get_holds(db, args: dict) -> list[TextContent]: + """The controller's blocking state for a work order, as JSON.""" + return json_response(get_holds_payload(db, args["execution_id"])) + + def _find_risk(db, args: dict) -> "Risk | None": """Resolve a non-deleted risk from risk_id or risk_number args.""" if args.get("risk_id") is not None: @@ -2878,7 +3429,7 @@ async def _get_project_info(db, args: dict) -> list[TextContent]: db.query(Issue) .filter( Issue.deleted_at.is_(None), - Issue.status.in_(["open", "investigating"]), + Issue.status == IssueStatus.OPEN, ) .count() ) @@ -4120,6 +4671,8 @@ async def _update_step(db, args: dict) -> list[TextContent]: step.required_role = args["required_role"] if "caution" in args: step.caution = args["caution"] + if "strict_sequence" in args: + step.strict_sequence = bool(args["strict_sequence"]) if "required_data_schema" in args: step.required_data_schema = args["required_data_schema"] @@ -4846,6 +5399,18 @@ async def _publish_version(db, args: dict) -> list[TextContent]: if prereq_order is not None: depends_on_map.setdefault(d.step_id, []).append(prereq_order) + from opal.db.models.procedure import StepImage + + all_images = ( + db.query(StepImage) + .filter(StepImage.step_id.in_(step_ids)) + .order_by(StepImage.position) + .all() + ) + images_map: dict[int, list[StepImage]] = {} + for img in all_images: + images_map.setdefault(img.step_id, []).append(img) + def step_to_dict(step: ProcedureStep) -> dict: return { "id": step.id, @@ -4861,8 +5426,13 @@ def step_to_dict(step: ProcedureStep) -> dict: "estimated_duration_minutes": step.estimated_duration_minutes, "required_role": step.required_role, "caution": step.caution, + "strict_sequence": step.strict_sequence, "workcenter_id": step.workcenter_id, "depends_on": sorted(depends_on_map.get(step.id, [])), + "images": [ + {"attachment_id": img.attachment_id, "caption": img.caption} + for img in images_map.get(step.id, []) + ], "step_kit": [ { "part_id": sk.part_id, @@ -5437,6 +6007,7 @@ def _part_exists(part_id: int) -> bool: instructions=step.get("instructions"), required_role=step.get("required_role"), caution=step.get("caution"), + strict_sequence=step.get("strict_sequence", False), requires_signoff=bool(step.get("requires_signoff", False)), estimated_duration_minutes=step.get("estimated_duration_minutes"), workcenter_id=step.get("workcenter_id"), @@ -5503,7 +6074,305 @@ def _part_exists(part_id: int) -> bool: ) -# ============ SERVER ENTRY POINT ============ +# ============ EXECUTION DOCUMENT TOOLS ============ + + +def _load_instance(db, args: dict) -> ProcedureInstance | None: + """Resolve an execution by execution_id or work_order string.""" + if args.get("execution_id") is not None: + return ( + db.query(ProcedureInstance).filter(ProcedureInstance.id == args["execution_id"]).first() + ) + if args.get("work_order"): + return ( + db.query(ProcedureInstance) + .filter(ProcedureInstance.work_order_number == args["work_order"]) + .first() + ) + return None + + +def _instance_error(args: dict) -> list[TextContent]: + ref = args.get("execution_id") or args.get("work_order") or "(none given)" + return json_response({"error": f"Execution {ref} not found"}) + + +def _load_step(db, instance: ProcedureInstance, args: dict) -> StepExecution | None: + return ( + db.query(StepExecution) + .filter( + StepExecution.instance_id == instance.id, + StepExecution.step_number == args["step_number"], + ) + .first() + ) + + +async def _get_execution_state(db, args: dict) -> list[TextContent]: + instance = _load_instance(db, args) + if not instance: + return _instance_error(args) + return json_response(build_execution_state(db, instance)) + + +async def _join_execution(db, args: dict) -> list[TextContent]: + instance = _load_instance(db, args) + if not instance: + return _instance_error(args) + user, error = _require_human_user(db, args) + if error: + return error + + participants = list(instance.participants or []) + existing = next((p for p in participants if p.get("user_id") == user.id), None) + if existing: + existing["last_active"] = datetime.now(UTC).isoformat() + else: + participants.append( + { + "user_id": user.id, + "user_name": user.name, + "joined_at": datetime.now(UTC).isoformat(), + "last_step": None, + } + ) + instance.participants = participants + db.commit() + + return json_response( + { + "success": True, + "message": f"{user.name} joined {instance.work_order_number or instance.id}", + "participants": instance.participants, + } + ) + + +async def _focus_step(db, args: dict) -> list[TextContent]: + instance = _load_instance(db, args) + if not instance: + return _instance_error(args) + step_exec = _load_step(db, instance, args) + if not step_exec: + return json_response({"error": f"Step {args['step_number']} not found"}) + user, error = _require_human_user(db, args) + if error: + return error + + focus = focus_step(db, instance, step_exec, user) + db.commit() + return json_response( + { + "success": True, + "message": ( + f"{user.name} at step {step_exec.step_number_str or step_exec.step_number}" + ), + "step_number": step_exec.step_number, + "focused_at": focus.focused_at.isoformat(), + } + ) + + +async def _complete_step(db, args: dict) -> list[TextContent]: + instance = _load_instance(db, args) + if not instance: + return _instance_error(args) + step_exec = _load_step(db, instance, args) + if not step_exec: + return json_response({"error": f"Step {args['step_number']} not found"}) + # Completing a step carries a human signature — declared trust. + user, error = _require_human_user(db, args) + if error: + return error + + try: + result = complete_step_flow( + db, + instance, + step_exec, + user.id, + data_captured=args.get("data"), + notes=args.get("notes"), + ) + except FlowError as err: + db.rollback() + return json_response({"error": err.message}) + if result.validation_errors: + db.rollback() + return json_response({"error": "Validation failed", "details": result.validation_errors}) + + db.commit() + return json_response( + { + "success": True, + "message": ( + f"Step {step_exec.step_number_str or step_exec.step_number} " + f"completed by {user.name}" + ), + "step_number": step_exec.step_number, + "instance_completed": result.instance_completed, + } + ) + + +async def _attach_to_step(db, args: dict) -> list[TextContent]: + import mimetypes + import uuid + from pathlib import Path + + from opal.db.models import Attachment + + instance = _load_instance(db, args) + if not instance: + return _instance_error(args) + step_exec = _load_step(db, instance, args) + if not step_exec: + return json_response({"error": f"Step {args['step_number']} not found"}) + + file_path = args.get("file_path") + content = args.get("content") + if not file_path and content is None: + return json_response({"error": "Provide file_path or content"}) + + if file_path: + src = Path(file_path) + if not src.is_file(): + return json_response({"error": f"File not found: {file_path}"}) + data = src.read_bytes() + original_name = src.name + mime = mimetypes.guess_type(src.name)[0] or "application/octet-stream" + else: + data = content.encode() + original_name = args.get("filename") or "note.txt" + mime = mimetypes.guess_type(original_name)[0] or "text/plain" + + settings = get_active_settings() + if len(data) > settings.max_upload_size: + return json_response( + {"error": f"File too large ({len(data)} bytes, max {settings.max_upload_size})"} + ) + + stored_name = f"{uuid.uuid4()}{Path(original_name).suffix}" + settings.upload_dir.mkdir(parents=True, exist_ok=True) + (settings.upload_dir / stored_name).write_bytes(data) + + user_id = args.get("user_id") + attachment = Attachment( + original_filename=original_name.replace("/", "_").replace("\\", "_")[:200], + stored_filename=stored_name, + mime_type=mime, + size_bytes=len(data), + procedure_instance_id=instance.id, + step_execution_id=step_exec.id, + kind="capture", + note=args.get("note"), + uploaded_by_id=user_id, + ) + db.add(attachment) + db.flush() + log_create(db, attachment, user_id) + db.commit() + + return json_response( + { + "success": True, + "message": ( + f"Attached {attachment.original_filename} to step " + f"{step_exec.step_number_str or step_exec.step_number}" + ), + "attachment_id": attachment.id, + } + ) + + +async def _add_step_note(db, args: dict) -> list[TextContent]: + instance = _load_instance(db, args) + if not instance: + return _instance_error(args) + step_exec = _load_step(db, instance, args) + if not step_exec: + return json_response({"error": f"Step {args['step_number']} not found"}) + + # One creation path: core add_step_note (also used by the JSON API). + # FlowError (empty body) raises before any write — nothing to roll back. + try: + note = flow_add_step_note(db, step_exec, args["note"], args.get("user_id")) + except FlowError as err: + return json_response({"error": err.message}) + db.commit() + db.refresh(note) + + return json_response( + { + "success": True, + "message": (f"Note added to step {step_exec.step_number_str or step_exec.step_number}"), + "note": { + "id": note.id, + "author": note.author.name if note.author else None, + "created_at": note.created_at.isoformat(), + "body": note.body, + }, + } + ) + + +async def _bind_issue_hold(db, args: dict) -> list[TextContent]: + """Bind = step containment with the bound step as the boundary. Delegates + to _set_containment — the narrowing-note rule, audit comment, and the + completion recheck all live there (one home).""" + instance = _load_instance(db, args) + if not instance: + return _instance_error(args) + step_exec = _load_step(db, instance, args) + if not step_exec: + return json_response({"error": f"Step {args['step_number']} not found"}) + user, error = _require_human_user(db, args) + if error: + return error + issue = db.query(Issue).filter(Issue.id == args["issue_id"], Issue.deleted_at.is_(None)).first() + if not issue: + return json_response({"error": f"Issue {args['issue_id']} not found"}) + + # A hold that names one work order cannot be bound into another's step — the + # containment filter is per-instance, so a cross-WO bind would report + # success and block nothing (F3). Adopt the target WO when the issue names + # none yet; reject when it names a different one. + if issue.procedure_instance_id is None: + issue.procedure_instance_id = instance.id + db.flush() + elif issue.procedure_instance_id != instance.id: + return json_response( + { + "error": f"{issue.issue_number} belongs to work order " + f"{issue.procedure_instance_id}, not this execution ({instance.id})" + } + ) + + containment = ( + issue.containment.value if hasattr(issue.containment, "value") else issue.containment + ) + if containment == Containment.STEP.value and issue.containment_step_id == step_exec.id: + label = step_exec.step_number_str or str(step_exec.step_number) + message = f"{issue.issue_number} already holds COMPLETE of step {label}" + # Commit before the early return (after the attribute reads — commit + # expires instances): the WO adoption above is only flushed, and + # call_tool's cleanup closes (rolls back) the session, so without + # this the adoption the cross-WO seam exists for is silently lost on + # the re-bind path. Mirrors the normal path, which commits inside + # _set_containment. + db.commit() + return json_response({"success": True, "message": message}) + + return await _set_containment( + db, + { + "issue_id": issue.id, + "containment": Containment.STEP.value, + "containment_step_id": step_exec.id, + "note": args.get("note"), + "user_id": user.id, + }, + ) async def run_server(): diff --git a/src/opal/seed.py b/src/opal/seed.py index d7d45c3..b95b437 100644 --- a/src/opal/seed.py +++ b/src/opal/seed.py @@ -1,7 +1,20 @@ -"""Seed data for Project Kestrel — LOX/ethanol pressure-fed sounding rocket.""" +"""Seed data for the Mojave Sphinx demo — a real liquid bipropellant rocket. +Demo content adapted from HCR-5100 — Mojave Sphinx Build, Integration, and +Launch Guidebook, Half Cat Rocketry, published under the GPL. The parts, +BOM, procedures, requirements, risks, and maintenance schedule are extracted +from the guidebook (lightly condensed); the live demo state (work orders, +issues, notes, purchases) is composed around real guidebook errata. + +Content lives in src/opal/seed_data/sphinx/*.json; this module is the loader +plus the demo-state narrative. +""" + +import json +import re from datetime import UTC, datetime, timedelta from decimal import Decimal +from pathlib import Path from typing import Any from sqlalchemy.orm import Session @@ -9,7 +22,9 @@ from opal.core.designators import ( generate_issue_number, generate_opal_number, + generate_requirement_number, generate_risk_number, + generate_serial_number, generate_work_order_number, ) from opal.db.models import ( @@ -26,36 +41,62 @@ ProcedureVersion, Purchase, PurchaseLine, + Requirement, Risk, RiskIssueLink, + StepDependency, StepExecution, Supplier, TestTemplate, User, Workcenter, ) -from opal.db.models.execution import InstanceStatus, StepStatus -from opal.db.models.inventory import InventoryRecord, SourceType -from opal.db.models.issue import IssuePriority, IssueStatus, IssueType -from opal.db.models.procedure import ProcedureStatus, ProcedureType +from opal.db.models.execution import InstanceStatus, StepFocus, StepNote, StepStatus +from opal.db.models.inventory import ( + InventoryConsumption, + InventoryProduction, + InventoryRecord, + ProductionStatus, + SourceType, +) +from opal.db.models.issue import Containment, DispositionType, IssuePriority, IssueStatus, IssueType +from opal.db.models.procedure import ProcedureStatus, ProcedureType, StepKit, UsageType from opal.db.models.purchase import PurchaseStatus -from opal.db.models.risk import RiskDisposition, RiskIssueRole +from opal.db.models.risk import RiskIssueRole +from opal.db.models.supplier import SupplierPart + +DATA_DIR = Path(__file__).resolve().parent / "seed_data" / "sphinx" + +ATTRIBUTION = ( + "Demo content adapted from HCR-5100 — Mojave Sphinx Build, Integration, " + "and Launch Guidebook, Half Cat Rocketry, published under the GPL." +) + + +def _data(name: str) -> dict[str, Any]: + return json.loads((DATA_DIR / name).read_text()) def seed_database(db: Session) -> None: - """Populate database with Project Kestrel seed data.""" + """Populate database with Mojave Sphinx seed data.""" + now = datetime.now(UTC) + + parts_data = _data("parts.json") + procs_data = _data("procedures.json") + plan = _stock_plan(parts_data, procs_data) + _seed_project_config(db) users = _seed_users(db) workcenters = _seed_workcenters(db) - suppliers = _seed_suppliers(db) - parts = _seed_parts(db) - _seed_bom(db, parts) - _seed_part_requirements(db, parts) - _seed_inventory(db, parts) - procedures = _seed_procedures(db, parts, workcenters) - _seed_versions_and_executions(db, procedures) - _seed_purchases(db, parts, suppliers) - issues = _seed_issues(db, parts, procedures) + suppliers = _seed_suppliers(db, parts_data) + parts = _seed_parts(db, parts_data, suppliers, users, now) + _seed_bom(db, parts_data, parts) + _seed_requirements(db, parts, users, now) + procs, versions = _seed_procedures(db, procs_data, parts, workcenters) + po_lines = _seed_purchases(db, parts_data, parts, suppliers, plan, now) + inventory = _seed_inventory(db, parts_data, parts, po_lines, plan, now) + wo = _seed_executions(db, procs, versions, parts, inventory, users, now) + issues = _seed_issues(db, parts, procs, wo, users, now) _seed_risks(db, parts, users, issues) _seed_test_templates(db, parts) db.commit() @@ -64,7 +105,8 @@ def seed_database(db: Session) -> None: print(f" {db.query(Supplier).count()} suppliers") print(f" {db.query(Part).count()} parts") print(f" {db.query(BOMLine).count()} BOM lines") - print(f" {db.query(PartRequirement).count()} part requirements") + print(f" {db.query(Requirement).count()} requirements") + print(f" {db.query(PartRequirement).count()} requirement allocations") print(f" {db.query(InventoryRecord).count()} inventory records") print(f" {db.query(MasterProcedure).count()} procedures") print(f" {db.query(ProcedureStep).count()} procedure steps") @@ -73,77 +115,31 @@ def seed_database(db: Session) -> None: print(f" {db.query(Purchase).count()} purchase orders") print(f" {db.query(PurchaseLine).count()} PO line items") print(f" {db.query(Issue).count()} issues") - print(f" {db.query(IssueComment).count()} issue comments") print(f" {db.query(Risk).count()} risks") print(f" {db.query(TestTemplate).count()} test templates") # --------------------------------------------------------------------------- -# Project YAML +# Project config # --------------------------------------------------------------------------- -_PROJECT_YAML = """\ -name: Project Kestrel -description: LOX/ethanol pressure-fed sounding rocket — target altitude 10 km -tiers: -- level: 1 - name: FLIGHT - code: F - description: Flight-critical hardware — full traceability required -- level: 2 - name: GROUND - code: G - description: Ground support equipment — test stands, fill systems, launch rail -- level: 3 - name: DEV - code: D - description: Development hardware, prototypes, consumables -part_numbering: - prefix: KST - separator: '-' - sequence_digits: 4 - format: '{prefix}{sep}{tier_code}{sep}{sequence}' -categories: -- Propulsion -- Structures -- Avionics -- Recovery -- Plumbing -- Fasteners -- GSE -- Raw Material -- Consumables -requirements: -- id: REQ-001 - title: Structural Loads - description: All flight structures shall withstand 10g axial and 3g lateral load simultaneously with positive margin of safety. -- id: REQ-002 - title: Pressure Containment - description: All pressure vessels and pressurized lines shall be proof tested to 1.5x MEOP before flight use. -- id: REQ-003 - title: LOX Compatibility - description: All materials in contact with liquid oxygen shall be LOX-compatible per NASA MSFC-SPEC-106B. -- id: REQ-004 - title: Dual-Event Recovery - description: Recovery system shall use dual-event deployment (drogue at apogee, main at low altitude) with redundant altimeters. -- id: REQ-005 - title: Flight Data Logging - description: Flight computer shall log all sensor data (acceleration, rotation, pressure, GPS) at 50 Hz minimum. -- id: REQ-006 - title: Telemetry Link - description: Telemetry downlink shall maintain 6 dB link margin to 10 km slant range. -- id: REQ-007 - title: Pyro Electrical Isolation - description: Pyrotechnic firing circuits shall be electrically isolated from avionics power bus with independent arming switch. -- id: REQ-008 - title: Flight Hardware Traceability - description: All flight components shall be traceable to lot number or serial number via OPAL inventory system. -cad_directories: [] -""" + +def _seed_project_config(db: Session) -> None: + """Store the Mojave Sphinx project config in the database (never on disk).""" + from opal.config import save_project_to_db + from opal.project import ProjectConfig + + config = ProjectConfig(**_data("project.json")["project"]) + save_project_to_db(db, config) + print(f" Stored project config '{config.name}' in the database") def _seed_users(db: Session) -> dict[str, User]: - """Create demo users with a known password (demo data, not production).""" + """Create demo users with a known password (demo data, not production). + + Usernames and password are kept from the previous demo (build/test/qa · + kestrel-demo) so existing docs and muscle memory survive the content swap. + """ from opal.core.auth import hash_password demo_hash = hash_password("kestrel-demo") @@ -152,22 +148,25 @@ def _seed_users(db: Session) -> dict[str, User]: name="Build Lead", username="build", password_hash=demo_hash, - email="build@kestrel.local", + email="build@sphinx.local", is_admin=True, + needs_onboarding=False, ), User( name="Test Engineer", username="test", password_hash=demo_hash, - email="test@kestrel.local", + email="test@sphinx.local", is_admin=False, + needs_onboarding=False, ), User( name="QA Inspector", username="qa", password_hash=demo_hash, - email="qa@kestrel.local", + email="qa@sphinx.local", is_admin=False, + needs_onboarding=False, ), ] db.add_all(items) @@ -175,35 +174,14 @@ def _seed_users(db: Session) -> dict[str, User]: return {u.username: u for u in items} -def _seed_project_config(db: Session) -> None: - """Store the Kestrel project config in the database (never on disk).""" - import yaml - - from opal.config import save_project_to_db - from opal.project import ProjectConfig - - config = ProjectConfig(**yaml.safe_load(_PROJECT_YAML)) - save_project_to_db(db, config) - print(f" Stored project config '{config.name}' in the database") - - -# --------------------------------------------------------------------------- -# Workcenters -# --------------------------------------------------------------------------- - - def _seed_workcenters(db: Session) -> dict[str, Workcenter]: items = [ + Workcenter(code="SHOP", name="Machine Shop", description="Lathe, drill press, cutting"), + Workcenter(code="BENCH", name="Assembly Bench", description="Vehicle and GSE assembly"), Workcenter( - code="SHOP", name="Machine Shop", description="Mill, lathe, welding, fabrication" - ), - Workcenter(code="CLEAN", name="Clean Room", description="Assembly bench, LOX-clean work"), - Workcenter(code="PAD", name="Test Pad", description="Static fire stand and launch rail"), - Workcenter( - code="LAB", - name="Electronics Lab", - description="Soldering, programming, avionics assembly", + code="ELEC", name="Electronics Bench", description="Avionics, wiring, transmitter setup" ), + Workcenter(code="PAD", name="Launch Site", description="Pad operations and static fire"), Workcenter(code="STORE", name="Stockroom", description="Parts receiving and storage"), ] db.add_all(items) @@ -211,49 +189,10 @@ def _seed_workcenters(db: Session) -> dict[str, Workcenter]: return {w.code: w for w in items} -# --------------------------------------------------------------------------- -# Suppliers -# --------------------------------------------------------------------------- - - -def _seed_suppliers(db: Session) -> dict[str, Supplier]: +def _seed_suppliers(db: Session, data: dict) -> dict[str, Supplier]: items = [ - Supplier( - name="McMaster-Carr", - email="", - website="https://www.mcmaster.com", - notes="Fasteners, plumbing, seals, raw stock. Next-day shipping.", - ), - Supplier( - name="Swagelok", - email="", - website="https://www.swagelok.com", - notes="High-pressure fittings, valves, regulators.", - ), - Supplier( - name="Digi-Key", - email="", - website="https://www.digikey.com", - notes="Electronics components, dev boards, connectors.", - ), - Supplier( - name="Metal Supermarkets", - email="", - website="https://www.metalsupermarkets.com", - notes="Cut-to-size aluminum, steel, stainless stock.", - ), - Supplier( - name="Apogee Components", - email="", - website="https://www.apogeerockets.com", - notes="Recovery hardware, parachutes, e-matches.", - ), - Supplier( - name="Airgas", - email="", - website="https://www.airgas.com", - notes="Industrial gases — LOX, nitrogen, helium.", - ), + Supplier(name=s["name"], website=s.get("website"), notes=s.get("notes")) + for s in data["suppliers"] ] db.add_all(items) db.flush() @@ -261,4249 +200,1308 @@ def _seed_suppliers(db: Session) -> dict[str, Supplier]: # --------------------------------------------------------------------------- -# Parts +# Parts & BOM # --------------------------------------------------------------------------- +# Reorder points for the consumables the launch procedures burn through. +_REORDER = { + "9452k226": 10, # -238 tank O-rings — replaced every 3 firings + "9452k15": 10, # -007 QD O-rings — QD tube every 5 firings + "94095k114": 2, # graphite gaskets — replaced on TCA disassembly + "1834": 8, # e-matches — 4 per flight (igniter + ejection) + "1318": 1, # black powder + "9v_batt": 2, # altimeter batteries +} + + +def _seed_parts( + db: Session, + data: dict, + suppliers: dict[str, Supplier], + users: dict[str, User], + now: datetime, +) -> dict[str, Part]: + parts: dict[str, Part] = {} + activated_at = now - timedelta(days=45) -def _seed_parts(db: Session) -> dict[str, Part]: - """Create ~50 parts. Returns dict keyed by short name for cross-referencing.""" - p: dict[str, Part] = {} - - def _add( - key: str, - ipn: str, - name: str, - *, - category: str, - tier: int = 1, - tracking: str = "serialized", - epn: str | None = None, - desc: str | None = None, - uom: str = "ea", - parent_key: str | None = None, - reorder: float | None = None, - is_tooling: bool = False, - cal_days: int | None = None, - ) -> None: + for entry in data["parts"]: + active = entry["lifecycle"] == "active" part = Part( - internal_pn=ipn, - external_pn=epn, - name=name, - description=desc, - category=category, - tier=tier, - tracking_type=tracking, - unit_of_measure=uom, - parent_id=p[parent_key].id if parent_key else None, - reorder_point=Decimal(str(reorder)) if reorder is not None else None, - is_tooling=is_tooling, - calibration_interval_days=cal_days, + internal_pn=entry["ipn"], + external_pn=entry.get("vendor_pn") or entry.get("guidebook_pn"), + name=entry["name"], + description=entry.get("description"), + category=entry["category"], + tier=entry["tier"], + tracking_type=entry["tracking"], + unit_of_measure=entry.get("uom") or "ea", + procurement=entry["procurement"], + is_tooling=bool(entry.get("is_tooling")), + reorder_point=( + Decimal(str(_REORDER[entry["key"]])) if entry["key"] in _REORDER else None + ), + lifecycle_state="active" if active else "draft", + activated_at=activated_at if active else None, + activated_by_id=users["build"].id if active else None, + activation_cause="Baseline release for the Sphinx build campaign" if active else None, ) db.add(part) db.flush() - p[key] = part - - # ── Propulsion ────────────────────────────────────────────── - _add( - "engine_assy", - "KST-F-0001", - "Engine Assembly", - category="Propulsion", - desc="Complete engine: chamber, injector, nozzle, igniter", - ) - _add( - "chamber", - "KST-F-0002", - "Combustion Chamber", - category="Propulsion", - desc="6061-T6 aluminum chamber, 450 PSI MEOP", - parent_key="engine_assy", - ) - _add( - "injector", - "KST-F-0003", - "Injector Plate", - category="Propulsion", - desc="304 SS, 32-element showerhead pattern", - parent_key="engine_assy", - ) - _add( - "nozzle", - "KST-F-0004", - "Nozzle", - category="Propulsion", - desc="Copper-lined graphite, 4:1 expansion ratio", - parent_key="engine_assy", - ) - _add( - "igniter", - "KST-F-0005", - "Igniter Assembly", - category="Propulsion", - desc="Pyrotechnic torch igniter with e-match", - parent_key="engine_assy", - ) - _add( - "lox_tank", - "KST-F-0006", - "LOX Tank", - category="Propulsion", - desc="6061-T6 welded tank, 500 PSI MEOP, 2.5 gal capacity", - ) - _add( - "fuel_tank", - "KST-F-0007", - "Fuel Tank", - category="Propulsion", - desc="6061-T6 welded tank, 500 PSI MEOP, 3.0 gal capacity", - ) - _add( - "press_tank", - "KST-F-0008", - "Pressurant Tank (N2)", - category="Propulsion", - desc="COTS nitrogen bottle, 3000 PSI rated", - ) - - # ── Plumbing ──────────────────────────────────────────────── - _add( - "lox_valve", - "KST-F-0009", - "Main LOX Valve", - category="Plumbing", - desc='Swagelok SS-63TS8 ball valve, 1/2" tube', - epn="SS-63TS8", - ) - _add( - "fuel_valve", - "KST-F-0010", - "Main Fuel Valve", - category="Plumbing", - desc='Swagelok SS-63TS8 ball valve, 1/2" tube', - epn="SS-63TS8", - ) - _add( - "check_valve", - "KST-F-0011", - 'Check Valve, 1/4" SS', - category="Plumbing", - tracking="bulk", - epn="4888K11", - desc='McMaster 4888K11 — 1/4" tube, 3000 PSI, cracking pressure 1/3 PSI', - reorder=4, - ) - _add( - "relief_valve", - "KST-F-0012", - "Pressure Relief Valve, 500 PSI", - category="Plumbing", - epn="48435K41", - desc='McMaster 48435K41 — adjustable, 1/4" NPT, brass body', - ) - _add( - "tube_half", - "KST-F-0013", - 'SS Tube, 1/2" OD x 0.035" Wall', - category="Plumbing", - tracking="bulk", - uom="ft", - epn="89895K427", - desc="McMaster 89895K427 — 304 SS, seamless, ASTM A269", - reorder=10, - ) - _add( - "tube_quarter", - "KST-F-0014", - 'SS Tube, 1/4" OD x 0.035" Wall', - category="Plumbing", - tracking="bulk", - uom="ft", - epn="89895K217", - desc="McMaster 89895K217 — 304 SS, seamless", - reorder=10, - ) - _add( - "an_fitting_half", - "KST-F-0015", - 'AN Flare Fitting, 1/2" Tube', - category="Plumbing", - tracking="bulk", - epn="5182K18", - desc="McMaster 5182K18 — 37° flare, 316 SS", - reorder=8, - ) - _add( - "an_fitting_quarter", - "KST-F-0016", - 'AN Flare Fitting, 1/4" Tube', - category="Plumbing", - tracking="bulk", - epn="5182K14", - desc="McMaster 5182K14 — 37° flare, 316 SS", - reorder=8, - ) - _add( - "teflon_tape", - "KST-D-0017", - 'Teflon Tape, 1/2" x 260"', - category="Consumables", - tier=3, - tracking="bulk", - epn="6802A13", - desc="McMaster 6802A13 — PTFE thread seal tape", - reorder=3, - ) - - # ── Structures ────────────────────────────────────────────── - _add( - "airframe", - "KST-F-0018", - 'Airframe Tube, 6" OD x 48"', - category="Structures", - desc='6061-T6 drawn tube, 0.065" wall', - ) - _add( - "nosecone", - "KST-F-0019", - 'Nose Cone, 6" 4:1 Ogive', - category="Structures", - desc='Fiberglass, 24" length, aluminum tip', - ) - _add( - "fin_set", - "KST-F-0020", - "Fin Set (3x)", - category="Structures", - desc='6061-T6 sheet, 0.125" thick, clipped delta planform', - ) - _add( - "bulkhead_fwd", - "KST-F-0021", - "Bulkhead, Forward", - category="Structures", - desc='6061-T6 plate, 6" OD, O-ring sealed, recovery harness attach', - ) - _add( - "bulkhead_aft", - "KST-F-0022", - "Bulkhead, Aft", - category="Structures", - desc='6061-T6 plate, 6" OD, engine mount interface, feedthrough ports', - ) - _add( - "coupler", - "KST-F-0023", - 'Coupler Tube, 6" ID x 8"', - category="Structures", - desc="6061-T6, connects airframe sections, shear-pinned for separation", - ) - _add( - "rail_button", - "KST-F-0024", - "Rail Button, 1515", - category="Structures", - tracking="bulk", - epn="97395A430", - desc='McMaster 97395A430 — Delrin, 1/4"-20 thread', - reorder=6, - ) - - # ── Avionics ──────────────────────────────────────────────── - _add( - "fc", - "KST-F-0025", - "Flight Computer", - category="Avionics", - desc="Custom PCB — Teensy 4.1, data logging, dual pyro channels", - ) - _add( - "gps", - "KST-F-0026", - "GPS Module, u-blox MAX-M10S", - category="Avionics", - epn="MAX-M10S", - desc="10 Hz update, SAW/LNA, active antenna connector", - ) - _add( - "imu", - "KST-F-0027", - "IMU, Bosch BNO055", - category="Avionics", - epn="BNO055", - desc="9-DOF absolute orientation sensor, I2C, sensor fusion onboard", - ) - _add( - "altimeter", - "KST-F-0028", - "Barometric Altimeter, MS5611", - category="Avionics", - epn="MS5611", - desc="24-bit ADC, 10 cm resolution, SPI/I2C", - ) - _add( - "radio", - "KST-F-0029", - "Telemetry Radio, RFM95W 915 MHz", - category="Avionics", - epn="RFM95W", - desc="LoRa spread spectrum, +20 dBm, SPI interface", - ) - _add( - "lipo", - "KST-F-0030", - "Battery, LiPo 2S 1000 mAh", - category="Avionics", - tracking="bulk", - desc="7.4V nominal, JST-XH balance connector, 20C discharge", - reorder=2, - ) - _add( - "harness", - "KST-F-0031", - "Wiring Harness, Avionics Bay", - category="Avionics", - desc="Point-to-point harness: FC, sensors, pyro, antenna, battery", - ) - _add( - "pyro_board", - "KST-F-0032", - "Pyro Channel Board", - category="Avionics", - desc="Dual MOSFET e-match firing circuit, optoisolated, LED-armed indicator", - ) - - # ── Recovery ──────────────────────────────────────────────── - _add( - "main_chute", - "KST-F-0033", - 'Main Parachute, 48" Cruciform', - category="Recovery", - desc="Ripstop nylon, 12 lb max descent load, Vd ≈ 18 ft/s", - ) - _add( - "drogue", - "KST-F-0034", - 'Drogue Parachute, 18" Hemispherical', - category="Recovery", - desc="Ripstop nylon, stabilizes descent to ~80 ft/s", - ) - _add( - "shock_cord", - "KST-F-0035", - 'Shock Cord, 1/2" Tubular Nylon', - category="Recovery", - tracking="bulk", - uom="ft", - desc="1500 lb rated, 20 ft working length", - reorder=25, - ) - _add( - "ubolt", - "KST-F-0036", - 'U-Bolt, 1/4"-20 x 1-1/2" Span', - category="Recovery", - tracking="bulk", - epn="3042T14", - desc="McMaster 3042T14 — forged steel, zinc-plated", - reorder=4, - ) - _add( - "shear_pin", - "KST-F-0037", - "Shear Pin, 2-56 Nylon", - category="Recovery", - tracking="bulk", - epn="90207A004", - desc="McMaster 90207A004 — nylon 6/6, calibrated shear for separation charge", - reorder=50, - ) - _add( - "ematch", - "KST-D-0038", - "E-Match, J-Tek", - category="Recovery", - tier=3, - tracking="bulk", - desc="Electric match, 1A/1W no-fire, bridgewire igniter for ejection charges", - reorder=10, - ) - - # ── Fasteners ─────────────────────────────────────────────── - _add( - "shcs_quarter", - "KST-D-0039", - 'SHCS 1/4"-20 x 1", 18-8 SS', - category="Fasteners", - tier=3, - tracking="bulk", - epn="91251A542", - desc="McMaster 91251A542 — socket head cap screw, fully threaded", - reorder=50, - ) - _add( - "shcs_10_32", - "KST-D-0040", - 'SHCS 10-32 x 3/4", 18-8 SS', - category="Fasteners", - tier=3, - tracking="bulk", - epn="91251A320", - desc="McMaster 91251A320 — socket head cap screw, fully threaded", - reorder=50, - ) - _add( - "hex_nut_quarter", - "KST-D-0041", - 'Hex Nut, 1/4"-20, 18-8 SS', - category="Fasteners", - tier=3, - tracking="bulk", - epn="91845A029", - desc="McMaster 91845A029", - reorder=50, - ) - _add( - "lock_washer_quarter", - "KST-D-0042", - 'Lock Washer, 1/4", 18-8 SS', - category="Fasteners", - tier=3, - tracking="bulk", - epn="92146A029", - desc="McMaster 92146A029 — split lock washer", - reorder=50, - ) - _add( - "oring_012", - "KST-D-0043", - "O-Ring, -012 Buna-N, 70A", - category="Fasteners", - tier=3, - tracking="bulk", - epn="9452K113", - desc='McMaster 9452K113 — AS568-012, 0.364" ID x 0.070" CS', - reorder=20, - ) - _add( - "oring_016", - "KST-D-0044", - "O-Ring, -016 Buna-N, 70A", - category="Fasteners", - tier=3, - tracking="bulk", - epn="9452K117", - desc='McMaster 9452K117 — AS568-016, 0.614" ID x 0.070" CS', - reorder=20, - ) - _add( - "oring_116", - "KST-F-0045", - "O-Ring, -116 Viton, 75A", - category="Fasteners", - tracking="bulk", - epn="9263K516", - desc='McMaster 9263K516 — AS568-116, LOX-compatible fluoroelastomer, 0.614" ID x 0.103" CS', - reorder=10, - ) - - # ── GSE ───────────────────────────────────────────────────── - _add( - "fill_valve", - "KST-G-0001", - "Fill/Drain Valve Assembly", - category="GSE", - tier=2, - desc="Ground-side fill panel: ball valve, vent, burst disc", - ) - _add( - "umbilical_qd", - "KST-G-0002", - "Umbilical Quick-Disconnect", - category="GSE", - tier=2, - desc="Swagelok QC4 series, auto-shutoff on disconnect", - epn="SS-QC4-B-400", - ) - _add( - "ign_box", - "KST-G-0003", - "Ignition Control Box", - category="GSE", - tier=2, - desc="Key-armed, dual-relay ignition circuit, 500 ft firing lead, continuity check", - ) - _add( - "launch_rail", - "KST-G-0004", - "Launch Rail, 20 ft 1515", - category="GSE", - tier=2, - desc="80/20 1515 aluminum extrusion, guyed, 85° elevation angle", - ) - _add( - "proof_fixture", - "KST-G-0005", - "Pressure Test Fixture", - category="GSE", - tier=2, - is_tooling=True, - cal_days=365, - desc="Hydrostatic proof test manifold: hand pump, gauge, relief valve, bleed", - ) - _add( - "ground_reg", - "KST-G-0006", - "Ground Regulator, N2", - category="GSE", - tier=2, - desc="Swagelok KPR series, 0-800 PSI outlet, CGA-580 inlet", - epn="KPR1FRA412A20000", - ) - - # ── Raw Material ──────────────────────────────────────────── - _add( - "al_plate", - "KST-D-0046", - '6061-T6 Al Plate, 1/2" Thick', - category="Raw Material", - tier=3, - tracking="bulk", - uom="sq ft", - epn="89015K28", - desc="McMaster 89015K28 — mill finish, AMS-QQ-A-250/11", - ) - _add( - "ss_sheet", - "KST-D-0047", - '304 SS Sheet, 0.060" Thick', - category="Raw Material", - tier=3, - tracking="bulk", - uom="sq ft", - epn="88885K58", - desc="McMaster 88885K58 — #2B finish, ASTM A240", - ) - _add( - "al_round", - "KST-D-0048", - '6061-T6 Al Round Bar, 3" OD', - category="Raw Material", - tier=3, - tracking="bulk", - uom="ft", - epn="8974K39", - desc="McMaster 8974K39 — AMS 4150, turned and polished", - ) - - return p - - -# --------------------------------------------------------------------------- -# BOM -# --------------------------------------------------------------------------- - - -def _seed_bom(db: Session, p: dict[str, Part]) -> None: - lines = [ - # Engine Assembly BOM - BOMLine(assembly_id=p["engine_assy"].id, component_id=p["chamber"].id, quantity=1), - BOMLine(assembly_id=p["engine_assy"].id, component_id=p["injector"].id, quantity=1), - BOMLine(assembly_id=p["engine_assy"].id, component_id=p["nozzle"].id, quantity=1), - BOMLine(assembly_id=p["engine_assy"].id, component_id=p["igniter"].id, quantity=1), - BOMLine( - assembly_id=p["engine_assy"].id, - component_id=p["shcs_quarter"].id, - quantity=8, - reference_designator="B1-B8", - notes="Chamber-to-injector bolts", - ), - BOMLine( - assembly_id=p["engine_assy"].id, - component_id=p["hex_nut_quarter"].id, - quantity=8, - reference_designator="N1-N8", - ), - BOMLine( - assembly_id=p["engine_assy"].id, - component_id=p["lock_washer_quarter"].id, - quantity=8, - reference_designator="W1-W8", - ), - BOMLine( - assembly_id=p["engine_assy"].id, - component_id=p["oring_116"].id, - quantity=2, - notes="Injector face seal + nozzle throat seal", - ), - BOMLine( - assembly_id=p["engine_assy"].id, - component_id=p["shcs_10_32"].id, - quantity=6, - reference_designator="B9-B14", - notes="Nozzle retainer ring", - ), - ] - db.add_all(lines) - db.flush() - - -# --------------------------------------------------------------------------- -# Part Requirements -# --------------------------------------------------------------------------- - - -def _seed_part_requirements(db: Session, p: dict[str, Part]) -> None: - now = datetime.now(UTC) - reqs: list[PartRequirement] = [] - - # REQ-001 Structural Loads → structures - for key in ["airframe", "nosecone", "fin_set", "bulkhead_fwd", "bulkhead_aft", "coupler"]: - reqs.append( - PartRequirement( - part_id=p[key].id, - requirement_id="REQ-001", - status="open", - ) - ) + parts[entry["key"]] = part - # REQ-002 Pressure Containment → pressure vessels and engine - for key in ["lox_tank", "fuel_tank", "press_tank", "chamber", "engine_assy"]: - status = "verified" if key == "press_tank" else "open" - reqs.append( - PartRequirement( - part_id=p[key].id, - requirement_id="REQ-002", - status=status, - verified_at=now - timedelta(days=12) if status == "verified" else None, - notes="COTS tank — vendor cert on file" if key == "press_tank" else None, - ) - ) - - # REQ-003 LOX Compatibility → wetted parts - for key in [ - "lox_tank", - "lox_valve", - "check_valve", - "relief_valve", - "tube_half", - "tube_quarter", - "an_fitting_half", - "an_fitting_quarter", - "injector", - "oring_116", - ]: - status = "verified" if key in ("tube_half", "tube_quarter") else "open" - reqs.append( - PartRequirement( - part_id=p[key].id, - requirement_id="REQ-003", - status=status, - verified_at=now - timedelta(days=20) if status == "verified" else None, - notes="304 SS — per MSFC-SPEC-106B Table 1" if status == "verified" else None, + if entry.get("supplier") and entry.get("vendor_pn"): + db.add( + SupplierPart( + supplier_id=suppliers[entry["supplier"]].id, + part_id=part.id, + vendor_pn=entry["vendor_pn"], + is_preferred=True, + ) ) - ) - # REQ-004 Recovery → recovery components and altimeter - for key in ["main_chute", "drogue", "fc", "altimeter", "pyro_board"]: - reqs.append( - PartRequirement( - part_id=p[key].id, - requirement_id="REQ-004", - status="open", - ) - ) + db.flush() + return parts - # REQ-005 Data Logging → flight computer + sensors - for key in ["fc", "imu", "altimeter", "gps"]: - reqs.append( - PartRequirement( - part_id=p[key].id, - requirement_id="REQ-005", - status="open", - ) - ) - # REQ-006 Telemetry Link → radio - reqs.append( - PartRequirement( - part_id=p["radio"].id, - requirement_id="REQ-006", - status="open", - ) - ) +def _seed_bom(db: Session, data: dict, parts: dict[str, Part]) -> None: + # parent_id mirrors the BOM only where a component has exactly one parent + parent_count: dict[str, list[str]] = {} + for line in data["bom"]: + parent_count.setdefault(line["component"], []).append(line["assembly"]) - # REQ-007 Pyro Isolation → pyro board - reqs.append( - PartRequirement( - part_id=p["pyro_board"].id, - requirement_id="REQ-007", - status="verified", - verified_at=now - timedelta(days=5), - notes="Bench tested: >500V isolation between pyro and logic rails", - ) - ) - - # REQ-008 Traceability → all flight tier parts (sample) - for key in [ - "engine_assy", - "chamber", - "injector", - "nozzle", - "lox_tank", - "fuel_tank", - "press_tank", - "fc", - "harness", - "main_chute", - ]: - status = "open" - if key in ("engine_assy", "chamber"): - status = "waived" - reqs.append( - PartRequirement( - part_id=p[key].id, - requirement_id="REQ-008", - status=status, - notes="Waived — traceability deferred to post-assembly serial assignment" - if status == "waived" - else None, + for line in data["bom"]: + db.add( + BOMLine( + assembly_id=parts[line["assembly"]].id, + component_id=parts[line["component"]].id, + quantity=int(line["qty"]), + notes=line.get("notes"), ) ) - - db.add_all(reqs) + if len(parent_count[line["component"]]) == 1: + parts[line["component"]].parent_id = parts[line["assembly"]].id db.flush() # --------------------------------------------------------------------------- -# Inventory +# Requirements # --------------------------------------------------------------------------- -def _seed_inventory(db: Session, p: dict[str, Part]) -> dict[str, InventoryRecord]: - inv: dict[str, InventoryRecord] = {} - - def _add(key: str, part_key: str, qty: float, location: str, **kw: object) -> None: - rec = InventoryRecord( - part_id=p[part_key].id, - opal_number=generate_opal_number(db), - quantity=Decimal(str(qty)), - location=location, - source_type=SourceType.MANUAL, - **kw, # type: ignore[arg-type] - ) - db.add(rec) - db.flush() - inv[key] = rec - - # Serialized flight hardware - _add("chamber_1", "chamber", 1, "CLEAN-1") - _add("injector_1", "injector", 1, "CLEAN-1") - _add("nozzle_1", "nozzle", 1, "SHOP-BENCH") - _add("igniter_1", "igniter", 1, "CLEAN-1") - _add("lox_tank_1", "lox_tank", 1, "STORE-A1") - _add("fuel_tank_1", "fuel_tank", 1, "STORE-A1") - _add("press_tank_1", "press_tank", 1, "STORE-A2") - _add("lox_valve_1", "lox_valve", 1, "STORE-B1") - _add("fuel_valve_1", "fuel_valve", 1, "STORE-B1") - _add("fc_1", "fc", 1, "LAB-BENCH") - _add("gps_1", "gps", 1, "LAB-BENCH") - _add("imu_1", "imu", 1, "LAB-BENCH") - _add("radio_1", "radio", 1, "LAB-BENCH") - _add("main_chute_1", "main_chute", 1, "STORE-C1") - _add("drogue_1", "drogue", 1, "STORE-C1") - _add("nosecone_1", "nosecone", 1, "STORE-A3") - _add("airframe_1", "airframe", 1, "STORE-A3") - - # Bulk stock - _add("tube_half_stock", "tube_half", 24, "STORE-B2", lot_number="LOT-2026-003") - _add("tube_quarter_stock", "tube_quarter", 18, "STORE-B2", lot_number="LOT-2026-004") - _add("an_half_stock", "an_fitting_half", 12, "STORE-B3") - _add("an_quarter_stock", "an_fitting_quarter", 16, "STORE-B3") - _add("shcs_quarter_stock", "shcs_quarter", 200, "STORE-D1", lot_number="LOT-2026-001") - _add("shcs_10_32_stock", "shcs_10_32", 150, "STORE-D1", lot_number="LOT-2026-001") - _add("hex_nut_stock", "hex_nut_quarter", 200, "STORE-D1") - _add("lock_washer_stock", "lock_washer_quarter", 200, "STORE-D1") - _add("oring_012_stock", "oring_012", 50, "STORE-D2") - _add("oring_016_stock", "oring_016", 50, "STORE-D2") - _add("oring_116_stock", "oring_116", 25, "STORE-D2") - _add("shear_pin_stock", "shear_pin", 100, "STORE-D3") - _add("ematch_stock", "ematch", 20, "STORE-D3") - _add("rail_button_stock", "rail_button", 8, "STORE-D3") - _add("shock_cord_stock", "shock_cord", 40, "STORE-C1") - _add("ubolt_stock", "ubolt", 6, "STORE-C1") - _add("lipo_stock", "lipo", 4, "LAB-SHELF") - _add("teflon_stock", "teflon_tape", 5, "STORE-D4") - - # Raw material - _add("al_plate_stock", "al_plate", 8, "STORE-E1", lot_number="LOT-MS-2026-A") - _add("ss_sheet_stock", "ss_sheet", 4, "STORE-E1", lot_number="LOT-MS-2026-B") - _add("al_round_stock", "al_round", 6, "STORE-E2", lot_number="LOT-MS-2026-C") - - # GSE - _add("proof_fixture_1", "proof_fixture", 1, "PAD-CART") - _add("ign_box_1", "ign_box", 1, "PAD-CART") - _add("launch_rail_1", "launch_rail", 1, "PAD") - _add("ground_reg_1", "ground_reg", 1, "PAD-CART") - - return inv - - -# --------------------------------------------------------------------------- -# Procedures -# --------------------------------------------------------------------------- - - -def _add_op_tree( +def _seed_requirements( db: Session, - procedure_id: int, - wc: dict[str, Workcenter], - ops: list[dict[str, Any]], + parts: dict[str, Part], + users: dict[str, User], + now: datetime, ) -> None: - """Insert a list of top-level operations and their sub-steps. - - Each op dict supports: title, instructions, duration, workcenter, sub_steps. - Each sub-step dict supports: title, instructions, duration, workcenter, - requires_signoff, schema. Step numbers and parent_step_id are auto-computed. - """ - order = 0 - for op_idx, op in enumerate(ops, start=1): - order += 1 - parent = ProcedureStep( - procedure_id=procedure_id, - order=order, - step_number=str(op_idx), - level=0, - title=op["title"], - instructions=op.get("instructions"), - estimated_duration_minutes=op.get("duration"), - workcenter_id=wc[op["workcenter"]].id if op.get("workcenter") else None, - requires_signoff=op.get("requires_signoff", False), - required_data_schema=op.get("schema"), + from opal.se.baseline import write_baseline_event + from opal.se.lifecycle import baseline + + data = _data("requirements.json") + baselined: list[Requirement] = [] + + for entry in data["requirements"]: + req = Requirement( + req_number=generate_requirement_number(db), + title=entry["title"], + statement=entry["statement"], + rationale=entry.get("rationale"), + category=entry["category"], + level=1, + verification_method=entry["verification_method"], ) - db.add(parent) + if entry["state"] == "tbr": + # A TBR carries an owner and an absolute closure date. + req.tbr = True + req.tbr_owner_id = users["test"].id + req.tbr_due = now + timedelta(days=21) + db.add(req) db.flush() - for sub_idx, sub in enumerate(op.get("sub_steps", []), start=1): - order += 1 + + if entry["state"] == "baselined": + baseline(db, req, users["build"].id) + baselined.append(req) + + for part_key in entry.get("allocations", []): + status = "open" + verified_at = None + notes = None + if entry["title"] == "Oxidizer tank static vent": + status = "verified" + verified_at = now - timedelta(days=15) + notes = "1.2 mm (0.047 in) vent drilled per OP 1; verified clear at tank assembly." db.add( - ProcedureStep( - procedure_id=procedure_id, - parent_step_id=parent.id, - order=order, - step_number=f"{op_idx}.{sub_idx}", - level=1, - title=sub["title"], - instructions=sub.get("instructions"), - estimated_duration_minutes=sub.get("duration"), - workcenter_id=wc[sub["workcenter"]].id if sub.get("workcenter") else None, - requires_signoff=sub.get("requires_signoff", False), - required_data_schema=sub.get("schema"), + PartRequirement( + part_id=parts[part_key].id, + requirement_id=req.req_number, + requirement_ref_id=req.id, + status=status, + verified_at=verified_at, + verified_by_id=users["qa"].id if verified_at else None, + notes=notes, ) ) - db.flush() - -def _seed_procedures( - db: Session, - p: dict[str, Part], - wc: dict[str, Workcenter], -) -> dict[str, MasterProcedure]: - procs: dict[str, MasterProcedure] = {} - - # ── 1. Engine Assembly Build ──────────────────────────────── - eng_build = MasterProcedure( - name="Engine Assembly Build", - description="Assemble combustion chamber, injector plate, nozzle, and igniter into complete engine unit.", - procedure_type=ProcedureType.BUILD, - status=ProcedureStatus.ACTIVE, + write_baseline_event( + db, + baselined, + users["build"].id, + label="hcr-5100-import", + note="Initial baseline of the HCR-5100 guidebook requirement set.", ) - db.add(eng_build) db.flush() - procs["eng_build"] = eng_build - - # Kit (parts consumed by this procedure) - for part_key, qty in [ - ("chamber", 1), - ("injector", 1), - ("nozzle", 1), - ("igniter", 1), - ("shcs_quarter", 8), - ("hex_nut_quarter", 8), - ("lock_washer_quarter", 8), - ("oring_116", 2), - ("shcs_10_32", 6), - ]: - db.add( - Kit( - procedure_id=eng_build.id, - part_id=p[part_key].id, - quantity_required=Decimal(str(qty)), - ) - ) - db.add( - ProcedureOutput( - procedure_id=eng_build.id, part_id=p["engine_assy"].id, quantity_produced=Decimal("1") - ) - ) - db.flush() - - _eng_build_ops: list[dict[str, Any]] = [ - { - "title": "Component Prep and Inspection", - "instructions": ( - "Inspect, clean, and stage all chamber, injector, nozzle, and igniter hardware " - "before assembly begins." - ), - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Visual Component Inspection", - "instructions": ( - "Inspect chamber bore, injector face, nozzle throat, and igniter housing " - "for nicks, machining swarf, or surface contamination." - ), - "duration": 15, - "workcenter": "CLEAN", - }, - { - "title": "Verify Dimensions Against Drawing", - "instructions": ( - "Check chamber ID, injector face flatness, and nozzle throat diameter " - "with calipers. Confirm all dimensions are within tolerance per drawing." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Solvent-Clean All Mating Surfaces", - "instructions": ( - "Wipe chamber flange, injector face, nozzle threads, and O-ring grooves " - "with isopropyl alcohol on a lint-free wipe. Blow dry with clean N2." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Stage Hardware Kit", - "instructions": ( - "Verify kit contents against BOM: 8x 1/4-20 SHCS, 8x lock washers, " - "8x hex nuts, 6x 10-32 SHCS, 2x -116 O-rings. Record O-ring and " - "fastener lot numbers on traveler." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Injector to Chamber Mating", - "instructions": "Loose-install the injector onto the chamber. No torque yet.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Install Injector O-Ring", - "instructions": ( - "Lubricate -116 O-ring with Krytox GPL-205 grease. Seat into injector " - "face groove. Verify O-ring sits flat with no twists or rolls." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Align Index Pin", - "instructions": ( - "Rotate injector so the index dowel pin aligns with the chamber socket " - "at 0 degrees clock position." - ), - "duration": 3, - "workcenter": "CLEAN", - }, - { - "title": "Lower Injector onto Chamber", - "instructions": ( - "Slowly lower the injector flat onto the chamber flange. Verify the " - "O-ring engages evenly without pinching or extruding." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Injector Bolt Torquing", - "instructions": "Install and final-torque the injector fasteners.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Install Injector Fasteners", - "instructions": ( - "Install 8x 1/4-20 SHCS with lock washers and hex nuts through the " - "injector flange. Tighten finger-tight in star pattern." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Torque to Spec in Star Pattern", - "instructions": ( - "Torque 8x 1/4-20 SHCS to 120 in-lb in two passes: 60 in-lb first pass, " - "120 in-lb second pass. Follow star pattern." - ), - "duration": 15, - "workcenter": "CLEAN", - "requires_signoff": True, - "schema": { - "fields": [ - { - "name": f"bolt{i}_torque", - "type": "number", - "label": f"Bolt {i} final torque (in-lb)", - "required": True, - } - for i in range(1, 9) - ] - }, - }, - { - "title": "Apply Witness Marks", - "instructions": ( - "Apply yellow torque-seal across each bolt head and flange. Photograph " - "flange from two angles." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Nozzle Installation", - "instructions": "Install nozzle and torque the retainer ring.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Install Nozzle O-Ring", - "instructions": ( - "Lubricate -116 O-ring with Krytox grease. Seat into nozzle throat " - "groove. Verify no twists." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Thread Nozzle into Chamber", - "instructions": ( - "Thread nozzle into chamber aft end. Hand-tight plus 1/4 turn. " - "Verify nozzle seats fully against the O-ring." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Install Nozzle Retainer Ring", - "instructions": ( - "Install retainer ring over nozzle. Insert 6x 10-32 SHCS and torque to " - "40 in-lb in alternating pattern." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - "schema": { - "fields": [ - { - "name": f"retainer_bolt{i}_torque", - "type": "number", - "label": f"Retainer bolt {i} torque (in-lb)", - "required": True, - } - for i in range(1, 7) - ] - }, - }, - ], - }, - { - "title": "Igniter Installation", - "instructions": "Thread igniter into injector boss and verify continuity.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Thread Igniter into Injector Boss", - "instructions": ( - "Apply thread sealant to igniter NPT threads. Thread into injector boss " - "hand-tight plus 1.5 turns. Torque to 25 ft-lb." - ), - "duration": 8, - "workcenter": "CLEAN", - }, - { - "title": "Route E-Match Leads", - "instructions": ( - "Route igniter leads up the chamber exterior, clear of the hot-gas " - "path. Tape leads to chamber wall with Kapton tape." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Igniter Continuity Check", - "instructions": ( - "Measure resistance across e-match terminals with multimeter. Acceptable " - "range 1.0 to 3.0 ohms." - ), - "duration": 3, - "workcenter": "CLEAN", - "schema": { - "fields": [ - { - "name": "igniter_ohms", - "type": "number", - "label": "Igniter resistance (ohms)", - "required": True, - } - ] - }, - }, - ], - }, - { - "title": "Pneumatic Leak Check", - "instructions": "Pressure-test all sealed joints with GN2 before closeout.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Cap Nozzle Exit", - "instructions": ( - "Install machined test cap on nozzle exit with -012 O-ring. Verify cap " - "seats and is hand-tight." - ), - "duration": 3, - "workcenter": "CLEAN", - }, - { - "title": "Pressurize to 50 PSI", - "instructions": ( - "Connect GN2 supply via pressure test fixture (KST-G-0005). Slowly " - "pressurize chamber to 50 PSI. Hold 60 seconds." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Soap-Bubble All Joints", - "instructions": ( - "Apply Snoop leak detector to injector flange, nozzle threads, igniter " - "boss, and cap seal. No bubbles permitted." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Depressurize and Remove Cap", - "instructions": ( - "Slowly vent chamber to 0 PSI. Disconnect GN2 supply. Remove nozzle " - "exit cap." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Final Inspection and Documentation", - "instructions": "Close out the build traveler and transfer engine to bonded storage.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Final Visual Inspection", - "instructions": ( - "Confirm all fasteners are torque-sealed, no FOD inside chamber, no " - "swarf on injector face, no damage to igniter leads." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Photograph Assembly", - "instructions": ( - "Take 4 photographs: fore (injector side), aft (nozzle side), left, " - "right. Include SN placard in each frame." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Record Assembly Data", - "instructions": ( - "Log SNs of chamber, injector, nozzle, igniter. Record O-ring lot, " - "fastener lot, and thread sealant batch numbers." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Build Traveler Sign-Off", - "instructions": ( - "Build lead and QA inspector both sign off that the engine is complete " - "and ready for hydrostatic proof test." - ), - "duration": 5, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - ], - }, - ] - _add_op_tree(db, eng_build.id, wc, _eng_build_ops) - - # ── 2. Hydrostatic Proof Test ─────────────────────────────── - hydro = MasterProcedure( - name="Hydrostatic Proof Test", - description="Proof test pressure vessel to 1.5x MEOP (675 PSI) with water. Verify no leaks or permanent deformation.", - procedure_type=ProcedureType.OP, - status=ProcedureStatus.ACTIVE, - ) - db.add(hydro) - db.flush() - procs["hydro"] = hydro - - _hydro_ops: list[dict[str, Any]] = [ - { - "title": "Test Setup", - "instructions": ( - "Plumb the test article to the pressure fixture, fill with distilled water, " - "and verify all instrumentation is reading nominal." - ), - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Connect Test Fixture", - "instructions": ( - "Connect pressure test fixture (KST-G-0005) to test article inlet via " - "1/4 inch SS tubing. Torque AN-4 fitting to 80 in-lb." - ), - "duration": 10, - "workcenter": "PAD", - }, - { - "title": "Fill with Distilled Water", - "instructions": ( - "Fill test article with distilled water through fill port until water " - "weeps from highest vent. Close vent." - ), - "duration": 8, - "workcenter": "PAD", - }, - { - "title": "Bleed Air from Lines", - "instructions": ( - "Open bleed valve and slowly stroke hand pump until water flows clear " - "with no entrained air. Close bleed valve." - ), - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Verify Instrumentation", - "instructions": ( - "Confirm pressure gauge reads 0 to 2 PSI. Verify data-acquisition " - "system is logging. Verify pressure relief valve set point at 750 PSI." - ), - "duration": 5, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Pressure Step — 100 PSI", - "instructions": "First low-pressure hold. Verify gross leak integrity.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Pressurize to 100 PSI", - "instructions": ( - "Slowly pressurize with hand pump at 10 PSI/sec. Stop at 100 PSI and " - "record reading." - ), - "duration": 5, - "workcenter": "PAD", - "schema": { - "fields": [ - { - "name": "pressure_100_psi", - "type": "number", - "label": "Pressure at hold (PSI)", - "required": True, - } - ] - }, - }, - { - "title": "Hold and Inspect for Leaks", - "instructions": ( - "Hold 60 seconds. Visually inspect all joints and weld lines. No " - "weeping or pressure decay permitted." - ), - "duration": 3, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Pressure Step — 300 PSI", - "instructions": "Intermediate pressure hold.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Pressurize to 300 PSI", - "instructions": "Slowly pressurize to 300 PSI. Record reading.", - "duration": 5, - "workcenter": "PAD", - "schema": { - "fields": [ - { - "name": "pressure_300_psi", - "type": "number", - "label": "Pressure at hold (PSI)", - "required": True, - } - ] - }, - }, - { - "title": "Hold and Inspect", - "instructions": "Hold 60 seconds. Inspect for weeping or audible hiss.", - "duration": 3, - "workcenter": "PAD", - }, - ], - }, - { - "title": "MEOP Hold — 500 PSI", - "instructions": "Maximum Expected Operating Pressure hold.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Pressurize to MEOP", - "instructions": "Slowly pressurize to 500 PSI. Record reading.", - "duration": 5, - "workcenter": "PAD", - "schema": { - "fields": [ - { - "name": "pressure_meop", - "type": "number", - "label": "Pressure at hold (PSI)", - "required": True, - } - ] - }, - }, - { - "title": "Hold 2 Minutes", - "instructions": ( - "Hold 120 seconds. Monitor pressure gauge for decay. Decay shall not " - "exceed 2 PSI." - ), - "duration": 3, - "workcenter": "PAD", - }, - { - "title": "Thorough Joint Inspection", - "instructions": ( - "While at MEOP, inspect every weld seam, fitting, and seal with a " - "flashlight. Mark any suspect areas with chalk." - ), - "duration": 5, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Proof Pressure Hold — 675 PSI", - "instructions": "Proof test at 1.35x MEOP. Verify no yielding or weeping.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Pressurize to Proof", - "instructions": ( - "Slowly pressurize to 675 PSI (1.35x MEOP). Approach proof pressure at " - "5 PSI/sec for the final 50 PSI." - ), - "duration": 8, - "workcenter": "PAD", - "requires_signoff": True, - "schema": { - "fields": [ - { - "name": "proof_pressure", - "type": "number", - "label": "Peak proof pressure (PSI)", - "required": True, - }, - { - "name": "hold_duration_s", - "type": "number", - "label": "Hold duration (seconds)", - "required": True, - }, - ] - }, - }, - { - "title": "Hold 5 Minutes", - "instructions": ( - "Hold 300 seconds. Pressure decay shall not exceed 5 PSI. Log decay rate." - ), - "duration": 6, - "workcenter": "PAD", - }, - { - "title": "Verify No Yielding or Weeping", - "instructions": ( - "Final inspection at proof pressure. Any weeping, audible hiss, or " - "visible deformation is a failure." - ), - "duration": 5, - "workcenter": "PAD", - "requires_signoff": True, - }, - ], - }, - { - "title": "Depressurization and Dimensional Check", - "instructions": "Vent back to zero and verify no permanent deformation.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Slow Depressurize to 0 PSI", - "instructions": ( - "Open bleed valve and vent slowly at 25 PSI/sec to atmosphere. " - "Disconnect supply once stable at 0." - ), - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Measure OD at 3 Stations", - "instructions": ( - "Measure outside diameter with pi-tape at three stations along the " - "test article. Compare to baseline measurements taken pre-test." - ), - "duration": 8, - "workcenter": "PAD", - "schema": { - "fields": [ - { - "name": "od_station_1", - "type": "number", - "label": "OD Station 1 (in)", - }, - { - "name": "od_station_2", - "type": "number", - "label": "OD Station 2 (in)", - }, - { - "name": "od_station_3", - "type": "number", - "label": "OD Station 3 (in)", - }, - ] - }, - }, - { - "title": "Visual Inspection for Cracks or Bulging", - "instructions": ( - "Inspect entire test article with bright light. Any visible cracks, " - "bulging, or surface distortion is a failure." - ), - "duration": 5, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Test Closeout", - "instructions": "Drain, dry, and record results.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Drain Test Article", - "instructions": ( - "Open drain valve and tilt to fully empty water. Catch in graduated " - "container to verify fill volume." - ), - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Dry with N2 Purge", - "instructions": ( - "Purge interior with dry GN2 for 5 minutes. Verify no residual moisture." - ), - "duration": 8, - "workcenter": "PAD", - }, - { - "title": "Record Pass or Fail", - "instructions": ( - "Test director records pass/fail in traveler. Attach pressure trace " - "and OD measurements to test report." - ), - "duration": 5, - "workcenter": "PAD", - "requires_signoff": True, - }, - ], - }, - ] - _add_op_tree(db, hydro.id, wc, _hydro_ops) - - # ── 3. Hot Fire Test ──────────────────────────────────────── - hotfire = MasterProcedure( - name="Static Hot Fire Test", - description="Ground test of assembled engine on test stand. 5-second burn at full thrust. Record chamber pressure, thrust, and burn time.", - procedure_type=ProcedureType.OP, - status=ProcedureStatus.ACTIVE, - ) - db.add(hotfire) - db.flush() - procs["hotfire"] = hotfire - - _hotfire_ops: list[dict[str, Any]] = [ - { - "title": "Pre-Test Planning", - "instructions": "Confirm plan, range, and weather before any propellant handling.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Review Test Plan and Abort Criteria", - "instructions": ( - "Walk through test card with all crew. Confirm each station knows " - "their role and the abort triggers." - ), - "duration": 15, - "workcenter": "PAD", - }, - { - "title": "Range Clear Confirmation", - "instructions": ( - "Range safety officer sweeps test area and confirms all non-essential " - "personnel are clear of the 500 ft exclusion zone." - ), - "duration": 10, - "workcenter": "PAD", - "requires_signoff": True, - }, - { - "title": "Weather Check", - "instructions": ( - "Verify winds under 20 kts, no lightning within 25 nm, visibility " - "greater than 3 nm. Document conditions." - ), - "duration": 5, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Engine Stand Installation", - "instructions": "Mount engine to the thrust stand and verify alignment.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Mount Engine to Adapter Plate", - "instructions": ( - "Lift engine onto thrust stand adapter plate. Engage 4x 3/8-16 mount " - "bolts finger-tight." - ), - "duration": 15, - "workcenter": "PAD", - }, - { - "title": "Torque Mount Bolts", - "instructions": ( - "Torque 4x 3/8-16 mount bolts to 240 in-lb in star pattern. Apply " - "Loctite 242." - ), - "duration": 10, - "workcenter": "PAD", - "requires_signoff": True, - "schema": { - "fields": [ - { - "name": f"bolt{i}_torque", - "type": "number", - "label": f"Mount bolt {i} torque (in-lb)", - "required": True, - } - for i in range(1, 5) - ] - }, - }, - { - "title": "Verify Engine Alignment", - "instructions": ( - "Check that nozzle axis is parallel to thrust stand load axis within " - "0.5 degrees. Adjust shims if needed." - ), - "duration": 10, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Propellant Plumbing", - "instructions": "Connect propellant and pressurant feedlines to the engine.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Connect LOX Feed Line", - "instructions": ( - "Connect 1/2 inch SS LOX line to engine LOX inlet. Torque AN-8 fitting " - "to 180 in-lb." - ), - "duration": 8, - "workcenter": "PAD", - }, - { - "title": "Connect Fuel Feed Line", - "instructions": ( - "Connect 1/2 inch SS ethanol line to engine fuel inlet. Torque AN-8 " - "fitting to 180 in-lb." - ), - "duration": 8, - "workcenter": "PAD", - }, - { - "title": "Connect Pressurant Lines", - "instructions": ( - "Connect 1/4 inch GN2 pressurant lines to LOX and fuel tank ullage " - "ports. Torque AN-4 to 80 in-lb." - ), - "duration": 8, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Instrumentation Hookup", - "instructions": "Connect all transducers and verify DAQ is capturing.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Connect Thrust Load Cell", - "instructions": "Attach load cell cable to DAQ channel 1. Verify excitation voltage.", - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Connect Chamber Pressure Transducer", - "instructions": ( - "Install Pc transducer in injector boss tap. Torque 1/8 NPT to 80 " - "in-lb with thread sealant. Connect to DAQ channel 2." - ), - "duration": 8, - "workcenter": "PAD", - }, - { - "title": "Connect Thermocouples", - "instructions": ( - "Install Type K thermocouples on chamber wall, nozzle throat, and " - "injector body. Route leads to DAQ channels 3 to 5." - ), - "duration": 10, - "workcenter": "PAD", - }, - { - "title": "Verify Data Acquisition Capture", - "instructions": ( - "Run DAQ self-test. Tap each transducer and verify signal appears on " - "the trace. Confirm sample rate at 1 kHz." - ), - "duration": 5, - "workcenter": "PAD", - "requires_signoff": True, - }, - ], - }, - { - "title": "Pneumatic Leak Check", - "instructions": "Pressure-test propellant lines with GN2 before loading.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Cap Engine Inlets", - "instructions": "Install test caps on engine LOX and fuel inlets.", - "duration": 3, - "workcenter": "PAD", - }, - { - "title": "Pressurize Lines to 50 PSI", - "instructions": ( - "Apply GN2 to both propellant lines at 50 PSI. Hold 60 seconds." - ), - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Soap-Bubble All Joints", - "instructions": ( - "Apply Snoop to every AN fitting, NPT joint, and valve body. No " - "bubbles permitted." - ), - "duration": 10, - "workcenter": "PAD", - "requires_signoff": True, - }, - { - "title": "Depressurize and Remove Caps", - "instructions": "Vent lines to 0 PSI. Remove test caps from engine inlets.", - "duration": 5, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Propellant Loading", - "instructions": "Load LOX and ethanol. Loading crew only on pad.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Clear Pad Area", - "instructions": ( - "All personnel except loading crew (2 max) clear to 100 ft. Loading " - "crew in PPE: face shield, cryo gloves, leather apron." - ), - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Fill LOX Tank", - "instructions": ( - "Slowly fill LOX tank from dewar through fill port. Target 2.5 gal. " - "Vent ullage to atmosphere during fill." - ), - "duration": 15, - "workcenter": "PAD", - "schema": { - "fields": [ - { - "name": "lox_fill_level", - "type": "number", - "label": "LOX fill level (gal)", - "required": True, - } - ] - }, - }, - { - "title": "Fill Ethanol Tank", - "instructions": "Fill ethanol tank from supply jug. Target 3.0 gal.", - "duration": 10, - "workcenter": "PAD", - "schema": { - "fields": [ - { - "name": "fuel_fill_level", - "type": "number", - "label": "Fuel fill level (gal)", - "required": True, - } - ] - }, - }, - { - "title": "Verify Fill Levels and Crack Vent", - "instructions": ( - "Confirm both tank sight glasses show target volume. Crack vent valves " - "to relieve pressure during fill cooldown." - ), - "duration": 5, - "workcenter": "PAD", - "requires_signoff": True, - }, - ], - }, - { - "title": "Tank Pressurization", - "instructions": "Bring tanks to 450 PSI pressurant.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Connect N2 Supply", - "instructions": "Open main N2 K-bottle valve. Verify supply pressure on regulator inlet.", - "duration": 3, - "workcenter": "PAD", - }, - { - "title": "Regulate to 450 PSI", - "instructions": ( - "Adjust ground regulator to deliver 450 PSI. Slowly open isolation " - "valves to tank ullage ports." - ), - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Verify Tank Pressures", - "instructions": "Read LOX and fuel tank ullage pressure. Both must be 440 to 460 PSI.", - "duration": 3, - "workcenter": "PAD", - "schema": { - "fields": [ - { - "name": "lox_tank_psi", - "type": "number", - "label": "LOX tank pressure (PSI)", - "required": True, - }, - { - "name": "fuel_tank_psi", - "type": "number", - "label": "Fuel tank pressure (PSI)", - "required": True, - }, - ] - }, - }, - ], - }, - { - "title": "Arm and Go/No-Go", - "instructions": "All personnel to bunker. Final arming and poll.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Clear All Personnel to Bunker", - "instructions": "Loading crew clears pad to bunker. Confirm all stations report clear.", - "duration": 3, - "workcenter": "PAD", - "requires_signoff": True, - }, - { - "title": "Arm Ignition System", - "instructions": ( - "Turn igniter arm key from SAFE to ARMED. Verify ARMED indicator LED " - "is lit and continuity is shown." - ), - "duration": 2, - "workcenter": "PAD", - "requires_signoff": True, - }, - { - "title": "Final Station Poll Go/No-Go", - "instructions": ( - "Test director polls each station by callsign: PROPS, DAQ, RANGE, " - "MED. Record GO from all before proceeding." - ), - "duration": 5, - "workcenter": "PAD", - "requires_signoff": True, - }, - ], - }, - { - "title": "Fire Sequence", - "instructions": "Execute ignition and main-stage burn.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Countdown from 5", - "instructions": "Test director calls 5-4-3-2-1 over comms. DAQ trigger armed at T-2.", - "duration": 1, - "workcenter": "PAD", - }, - { - "title": "Igniter Command", - "instructions": ( - "At T-0 fire igniter. Verify pre-burner light through pad camera " - "within 200 ms." - ), - "duration": 1, - "workcenter": "PAD", - }, - { - "title": "Main Valve Open Command", - "instructions": ( - "T+0.5s: open main LOX and fuel valves simultaneously. Run 5 seconds. " - "Monitor chamber pressure trace live." - ), - "duration": 1, - "workcenter": "PAD", - }, - { - "title": "Engine Shutdown", - "instructions": "T+5.5s: close both main valves. Engine should extinguish within 100 ms.", - "duration": 1, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Safe-State", - "instructions": "Vent system and disarm before any pad approach.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Close Main Valves", - "instructions": "Confirm LOX and fuel main valves indicate CLOSED on control panel.", - "duration": 2, - "workcenter": "PAD", - }, - { - "title": "Vent Tank Ullage", - "instructions": ( - "Open tank vent valves and bleed ullage pressure to 0 PSI. Monitor " - "tank pressure gauges." - ), - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Safe Ignition System", - "instructions": "Turn igniter key to SAFE and remove. Confirm ARMED LED off.", - "duration": 2, - "workcenter": "PAD", - "requires_signoff": True, - }, - { - "title": "Approach and Inspect for Residual Fire", - "instructions": ( - "Wait 5 minutes after shutdown before approach. RSO leads team to " - "pad with extinguisher. Verify no residual fire or smoldering." - ), - "duration": 8, - "workcenter": "PAD", - "requires_signoff": True, - }, - ], - }, - { - "title": "Data Capture and Post-Fire Inspection", - "instructions": "Pull DAQ files and inspect engine hardware.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Download DAQ Files", - "instructions": ( - "Download thrust, Pc, and temperature traces from DAQ. Verify file " - "integrity. Record peak values." - ), - "duration": 10, - "workcenter": "PAD", - "schema": { - "fields": [ - { - "name": "peak_chamber_psi", - "type": "number", - "label": "Peak Pc (PSI)", - "required": True, - }, - { - "name": "peak_thrust_lbf", - "type": "number", - "label": "Peak thrust (lbf)", - "required": True, - }, - { - "name": "burn_time_s", - "type": "number", - "label": "Burn time (seconds)", - "required": True, - }, - ] - }, - }, - { - "title": "Inspect Nozzle Throat", - "instructions": ( - "Visual inspection of nozzle throat. Check for erosion, cracks, or " - "ablative loss. Photograph." - ), - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Inspect Injector Face", - "instructions": ( - "Borescope injector face. Check for orifice erosion, soot patterns, " - "or hot spots. Photograph." - ), - "duration": 5, - "workcenter": "PAD", - }, - { - "title": "Inspect Chamber Walls", - "instructions": ( - "Borescope chamber walls. Check for erosion, hot streaks, or " - "discoloration. Photograph." - ), - "duration": 5, - "workcenter": "PAD", - }, - ], - }, - { - "title": "Stand Removal and Debrief", - "instructions": "Disconnect, remove engine, and review data.", - "duration": 5, - "workcenter": "PAD", - "sub_steps": [ - { - "title": "Disconnect Lines and Instrumentation", - "instructions": ( - "Disconnect all propellant lines, pressurant lines, and instrumentation " - "cables. Cap engine ports." - ), - "duration": 15, - "workcenter": "PAD", - }, - { - "title": "Remove Engine from Stand", - "instructions": ( - "Break 4x mount bolts. Lift engine off adapter plate. Transfer to " - "transport cart." - ), - "duration": 10, - "workcenter": "PAD", - }, - { - "title": "Data Review vs Predictions", - "instructions": ( - "Compare measured Pc, thrust, and burn time against predicted values. " - "Note any deviations greater than 10%." - ), - "duration": 20, - "workcenter": "PAD", - }, - { - "title": "Test Debrief and Sign-Off", - "instructions": ( - "Whole team debrief: lessons learned, anomalies, action items. Test " - "director signs off on test report." - ), - "duration": 15, - "workcenter": "PAD", - "requires_signoff": True, - }, - ], - }, - ] - _add_op_tree(db, hotfire.id, wc, _hotfire_ops) - - # ── 4. Avionics Integration ───────────────────────────────── - avi = MasterProcedure( - name="Avionics Bay Integration", - description="Assemble flight computer, sensors, radio, pyro board, and battery onto avionics sled. Build and test wiring harness.", - procedure_type=ProcedureType.BUILD, - status=ProcedureStatus.DRAFT, - ) - db.add(avi) - db.flush() - procs["avi"] = avi - - for part_key, qty in [ - ("fc", 1), - ("gps", 1), - ("imu", 1), - ("altimeter", 1), - ("radio", 1), - ("lipo", 1), - ("pyro_board", 1), - ]: - db.add( - Kit(procedure_id=avi.id, part_id=p[part_key].id, quantity_required=Decimal(str(qty))) - ) - db.add( - ProcedureOutput( - procedure_id=avi.id, part_id=p["harness"].id, quantity_produced=Decimal("1") - ) - ) - db.flush() - - _avi_ops: list[dict[str, Any]] = [ - { - "title": "Sled Preparation", - "instructions": "Clean sled and install all standoffs before any components touch the board.", - "duration": 5, - "workcenter": "LAB", - "sub_steps": [ - { - "title": "Clean Sled", - "instructions": ( - "Wipe avionics sled with isopropyl alcohol on a lint-free cloth. " - "Blow off with clean N2." - ), - "duration": 5, - "workcenter": "LAB", - }, - { - "title": "Install Standoffs", - "instructions": ( - "Install M3 brass standoffs at flight computer, pyro board, GPS, and " - "radio mounting locations per assembly drawing." - ), - "duration": 10, - "workcenter": "LAB", - }, - { - "title": "Verify Mounting Hole Pattern", - "instructions": ( - "Test-fit each board on its standoffs. Confirm no hole misalignment " - "before applying torque." - ), - "duration": 5, - "workcenter": "LAB", - }, - ], - }, - { - "title": "Flight Computer Mount", - "instructions": "Mount Teensy 4.1 and verify USB access.", - "duration": 5, - "workcenter": "LAB", - "sub_steps": [ - { - "title": "Position FC on Standoffs", - "instructions": "Place Teensy 4.1 onto its standoffs with USB port facing the access window.", - "duration": 3, - "workcenter": "LAB", - }, - { - "title": "Secure with M3 Screws", - "instructions": "Secure with 4x M3x6 screws. Torque to 4 in-lb. Do not over-tighten.", - "duration": 5, - "workcenter": "LAB", - }, - { - "title": "Verify USB Port Accessibility", - "instructions": ( - "Confirm USB-C cable seats and disconnects without binding through the " - "access window." - ), - "duration": 2, - "workcenter": "LAB", - }, - { - "title": "Bench Continuity Check", - "instructions": "Connect FC to bench supply at 5V. Verify board enumerates over USB.", - "duration": 5, - "workcenter": "LAB", - }, - ], - }, - { - "title": "Sensor Installation", - "instructions": "Mount IMU, altimeter, GPS and connect data buses.", - "duration": 5, - "workcenter": "LAB", - "sub_steps": [ - { - "title": "Install BNO055 IMU", - "instructions": ( - "Mount BNO055 breakout on standoffs with X-axis aligned to vehicle " - "roll axis per drawing. Secure with M3 screws." - ), - "duration": 8, - "workcenter": "LAB", - }, - { - "title": "Install MS5611 Altimeter", - "instructions": ( - "Mount MS5611 breakout. Verify barometric port is unblocked and faces " - "the bay vent." - ), - "duration": 5, - "workcenter": "LAB", - }, - { - "title": "Install MAX-M10S GPS", - "instructions": ( - "Mount GPS module with antenna patch facing the airframe outer wall " - "for sky visibility." - ), - "duration": 5, - "workcenter": "LAB", - }, - { - "title": "Connect Sensor I2C and UART Cables", - "instructions": ( - "Connect IMU and altimeter to I2C bus pins. Connect GPS UART to FC " - "Serial1. Verify pin assignments against wiring diagram." - ), - "duration": 10, - "workcenter": "LAB", - }, - ], - }, - { - "title": "Pyro Board Installation", - "instructions": "Install and verify the pyro channel board.", - "duration": 5, - "workcenter": "LAB", - "sub_steps": [ - { - "title": "Install Pyro Channel Board on Standoffs", - "instructions": "Mount pyro board with screw terminals facing the bulkhead penetration.", - "duration": 5, - "workcenter": "LAB", - }, - { - "title": "Connect Optoisolator Ribbon to FC", - "instructions": ( - "Connect 10-conductor ribbon from pyro board to FC pyro control " - "header. Verify ribbon orientation by red stripe on pin 1." - ), - "duration": 5, - "workcenter": "LAB", - }, - { - "title": "Continuity Check Both Channels", - "instructions": ( - "With pyro board unpowered, verify continuity between FC pyro pins " - "and screw terminals using multimeter." - ), - "duration": 5, - "workcenter": "LAB", - "schema": { - "fields": [ - { - "name": "drogue_channel_ohms", - "type": "number", - "label": "Drogue channel continuity (ohms)", - "required": True, - }, - { - "name": "main_channel_ohms", - "type": "number", - "label": "Main channel continuity (ohms)", - "required": True, - }, - ] - }, - }, - ], - }, - { - "title": "Radio and Antenna", - "instructions": "Install telemetry radio and route antenna feed.", - "duration": 5, - "workcenter": "LAB", - "sub_steps": [ - { - "title": "Install RFM95W Module", - "instructions": "Mount RFM95W on its standoffs. Connect SPI ribbon to FC.", - "duration": 8, - "workcenter": "LAB", - }, - { - "title": "Route Antenna Coax", - "instructions": ( - "Route 50 ohm coax from RFM95W u.FL to airframe bulkhead. Avoid sharp " - "bends (minimum 1 inch radius)." - ), - "duration": 5, - "workcenter": "LAB", - }, - { - "title": "Install SMA Bulkhead Connector", - "instructions": "Install SMA bulkhead through coupler. Torque SMA to 5 in-lb.", - "duration": 5, - "workcenter": "LAB", - }, - ], - }, - { - "title": "Wiring Harness Build", - "instructions": "Build the harness on the sled per harness drawing.", - "duration": 5, - "workcenter": "LAB", - "sub_steps": [ - { - "title": "Cut and Strip Wires per Drawing", - "instructions": ( - "Cut each wire to length and strip 5 mm per harness drawing. Label " - "both ends with shrink-wrap tags." - ), - "duration": 20, - "workcenter": "LAB", - }, - { - "title": "Terminate Connectors", - "instructions": ( - "Crimp Molex SL connectors on each terminated wire. Pull-test each " - "crimp at 5 lbf." - ), - "duration": 25, - "workcenter": "LAB", - }, - { - "title": "Lace Harness with Waxed Cord", - "instructions": "Lace harness bundle with waxed cord every 25 mm. Tie off neatly.", - "duration": 15, - "workcenter": "LAB", - }, - { - "title": "Continuity Check Each Wire", - "instructions": ( - "Beep out every wire end-to-end with multimeter. Confirm no shorts " - "between adjacent pins." - ), - "duration": 15, - "workcenter": "LAB", - "requires_signoff": True, - }, - ], - }, - { - "title": "Power-On Test and Closeout", - "instructions": "Power up and verify all subsystems before final inspection.", - "duration": 5, - "workcenter": "LAB", - "sub_steps": [ - { - "title": "Install LiPo Battery", - "instructions": ( - "Install 2S 1500 mAh LiPo in battery holder. Confirm polarity. " - "Connect with XT30." - ), - "duration": 3, - "workcenter": "LAB", - }, - { - "title": "Verify Boot Sequence", - "instructions": ( - "Power FC. Watch USB serial for boot banner. Verify firmware version " - "matches build manifest." - ), - "duration": 5, - "workcenter": "LAB", - "requires_signoff": True, - }, - { - "title": "Verify Sensor Responses", - "instructions": ( - "Issue self-test command. Confirm IMU returns valid quaternion, GPS " - "acquires fix within 90 s, altimeter reads ground pressure." - ), - "duration": 8, - "workcenter": "LAB", - "schema": { - "fields": [ - { - "name": "imu_status", - "type": "string", - "label": "IMU self-test result", - "required": True, - }, - { - "name": "gps_fix_count", - "type": "number", - "label": "GPS satellites locked", - "required": True, - }, - { - "name": "altimeter_ground_psi", - "type": "number", - "label": "Ground pressure (mbar)", - "required": True, - }, - ] - }, - }, - { - "title": "Verify Radio TX Test Packet", - "instructions": ( - "Trigger test packet TX. Confirm ground station receives at RSSI " - "greater than -90 dBm at 10 m range." - ), - "duration": 5, - "workcenter": "LAB", - }, - { - "title": "Photograph Completed Assembly", - "instructions": ( - "Take 4 photos: top, bottom, both ends. Include build SN placard " - "in each frame." - ), - "duration": 5, - "workcenter": "LAB", - }, - { - "title": "Final Sign-Off", - "instructions": ( - "Verify connector seating, wire routing, and chafe protection. " - "Avionics lead signs off." - ), - "duration": 10, - "workcenter": "LAB", - "requires_signoff": True, - }, - ], - }, - ] - _add_op_tree(db, avi.id, wc, _avi_ops) - - # ── 5. Recovery System Pack ───────────────────────────────── - rec = MasterProcedure( - name="Recovery System Pack", - description="Fold and pack main and drogue parachutes, install ejection charges, and verify continuity.", - procedure_type=ProcedureType.OP, - status=ProcedureStatus.ACTIVE, - ) - db.add(rec) - db.flush() - procs["recovery"] = rec - - _rec_ops: list[dict[str, Any]] = [ - { - "title": "Parachute Inspection", - "instructions": "Inspect both canopies and lines before any folding.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Unfold Drogue", - "instructions": "Lay drogue (KST-F-0034) flat on clean inspection table. Spread canopy fully.", - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Unfold Main", - "instructions": "Lay main canopy (KST-F-0033) flat on clean inspection table adjacent to drogue.", - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Inspect Canopies for Tears or Burn-Through", - "instructions": ( - "Inspect both canopies under bright light. No tears, burn marks, " - "fabric weakness, or seam separation." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Inspect Shroud Lines for Fraying", - "instructions": ( - "Run gloved hand along every shroud line. No fraying, knots, or " - "abrasion. Verify all lines uniform length." - ), - "duration": 8, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - ], - }, - { - "title": "Drogue Pack", - "instructions": "Z-fold and bag the drogue.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Z-Fold Drogue Canopy", - "instructions": ( - "Z-fold drogue canopy into 6 inch wide bundle per packing card. " - "Maintain symmetry." - ), - "duration": 8, - "workcenter": "CLEAN", - }, - { - "title": "Bundle Shroud Lines", - "instructions": ( - "Daisy-chain shroud lines into a loose bundle on top of folded canopy. " - "Do not knot." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Insert in Deployment Bag and Close", - "instructions": ( - "Insert bundle into deployment bag. Pull rubber band closure. Verify " - "bridle exits cleanly." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Main Pack", - "instructions": "Accordion-fold and bag the main chute.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Accordion-Fold Main Canopy", - "instructions": ( - "Accordion-fold main canopy per packing card into 8 inch wide bundle. " - "Compress to deployment bag width." - ), - "duration": 12, - "workcenter": "CLEAN", - }, - { - "title": "Bundle Main Shroud Lines", - "instructions": "Daisy-chain shroud lines on top of folded canopy. Avoid twists.", - "duration": 6, - "workcenter": "CLEAN", - }, - { - "title": "Insert in Deployment Bag and Close", - "instructions": ( - "Slide bundle into deployment bag. Close with bungee. Confirm pilot " - "chute attached." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Ejection Charge Loading", - "instructions": "Load black powder charges. PYRO CREW ONLY in the area.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Clear Area to Pyro Crew Only", - "instructions": ( - "All non-pyro personnel clear the room. Pyro crew dons PPE: safety " - "glasses, anti-static wrist strap." - ), - "duration": 3, - "workcenter": "CLEAN", - }, - { - "title": "Measure and Load Drogue Charge", - "instructions": ( - "Weigh 2.5 g FFFFg black powder on anti-static scale. Load into drogue " - "charge well. Record actual mass." - ), - "duration": 5, - "workcenter": "CLEAN", - "schema": { - "fields": [ - { - "name": "drogue_charge_g", - "type": "number", - "label": "Drogue charge (grams)", - "required": True, - } - ] - }, - }, - { - "title": "Measure and Load Main Charge", - "instructions": ( - "Weigh 4.0 g FFFFg black powder. Load into main charge well. Record " - "actual mass." - ), - "duration": 5, - "workcenter": "CLEAN", - "schema": { - "fields": [ - { - "name": "main_charge_g", - "type": "number", - "label": "Main charge (grams)", - "required": True, - } - ] - }, - }, - { - "title": "Install E-Matches in Charge Wells", - "instructions": ( - "Bed e-matches (KST-D-0038) in each charge well. Tape leads outboard " - "along bay wall." - ), - "duration": 5, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - ], - }, - { - "title": "Continuity Verification", - "instructions": "Confirm both pyro channels show in-spec resistance.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Connect Multimeter to Drogue Circuit", - "instructions": "Connect multimeter across drogue pyro terminals. Set to 200 ohm range.", - "duration": 2, - "workcenter": "CLEAN", - }, - { - "title": "Record Drogue Circuit Resistance", - "instructions": "Read and record drogue circuit. Acceptable range 1.0 to 3.5 ohms.", - "duration": 2, - "workcenter": "CLEAN", - "schema": { - "fields": [ - { - "name": "drogue_ohms", - "type": "number", - "label": "Drogue circuit (ohms)", - "required": True, - } - ] - }, - }, - { - "title": "Record Main Circuit Resistance", - "instructions": "Connect across main pyro terminals. Read and record. Same acceptable range.", - "duration": 2, - "workcenter": "CLEAN", - "schema": { - "fields": [ - { - "name": "main_ohms", - "type": "number", - "label": "Main circuit (ohms)", - "required": True, - } - ] - }, - }, - { - "title": "Verify Both Channels in Spec", - "instructions": ( - "Pyro lead confirms both circuits are in spec and signs off before " - "closeout." - ), - "duration": 3, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - ], - }, - { - "title": "Shear Pin Installation", - "instructions": "Install separation-joint shear pins and verify coupler engagement.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Install Drogue Joint Shear Pins", - "instructions": ( - "Install 3x nylon shear pins (KST-F-0037) at 120 degree spacing through " - "drogue separation joint. Pins flush with airframe." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Install Main Joint Shear Pins", - "instructions": ( - "Install 3x nylon shear pins at 120 degree spacing through main " - "separation joint. Pins flush with airframe." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Verify Coupler Alignment", - "instructions": ( - "Visually verify both couplers are fully engaged with no gaps. Run " - "straight edge across each joint." - ), - "duration": 5, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - ], - }, - ] - _add_op_tree(db, rec.id, wc, _rec_ops) - - # ── 6. Final Vehicle Integration ──────────────────────────── - fvi = MasterProcedure( - name="Final Vehicle Integration", - description=( - "Stack all flight subsystems into complete vehicle. Uses operation/step " - "hierarchy: each operation groups related steps for loose install, final " - "install, alignment, safety wire, and closeout." - ), - procedure_type=ProcedureType.BUILD, - status=ProcedureStatus.DRAFT, - ) - db.add(fvi) - db.flush() - procs["fvi"] = fvi - - _fvi_ops: list[dict[str, Any]] = [ - { - "title": "Preparation and Staging", - "instructions": ( - "Set up clean work area. Gather and verify all subassemblies, hardware, " - "and tooling required for integration." - ), - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Clean Work Surface", - "instructions": ( - "Wipe down integration bench with isopropyl alcohol. Lay out clean " - "ESD-safe mat." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Stage Subassemblies", - "instructions": ( - "Place the following on bench: engine assembly (KST-F-0001), LOX tank " - "(KST-F-0006), fuel tank (KST-F-0007), avionics sled, recovery bay, " - "forward bulkhead (KST-F-0021), aft bulkhead (KST-F-0022), airframe " - "tube (KST-F-0018), nose cone (KST-F-0019), fin set (KST-F-0020), " - "coupler tubes (KST-F-0023)." - ), - "duration": 15, - "workcenter": "CLEAN", - }, - { - "title": "Verify Subassembly Serial Numbers", - "instructions": ( - "Record serial numbers of all flight subassemblies. Cross-reference " - "against traveler. Confirm all items have passed incoming inspection " - "or prior build procedures." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Inventory Hardware Kit", - "instructions": ( - "Verify kit contents against BOM: 1/4 inch-20 SHCS (qty 16), 10-32 " - "SHCS (qty 12), 1/4 inch-20 hex nuts (qty 16), lock washers (qty 16), " - "rail buttons (qty 2), shear pins (qty 6), U-bolts (qty 4), O-rings " - "-012 (qty 4), O-rings -016 (qty 2). Mark checklist." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Aft Section — Loose Install", - "instructions": ( - "Loose-fit aft bulkhead, engine assembly, and thrust structure into the aft " - "end of the airframe. All fasteners finger-tight only." - ), - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Loose-Install Aft Bulkhead", - "instructions": ( - "Insert aft bulkhead (KST-F-0022) into airframe tube. Align " - "feedthrough ports to 0 degree clock position. Install retaining " - "ring finger-tight." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Loose-Install Engine Assembly", - "instructions": ( - "Slide engine assembly into aft section. Engage mount flange bolts " - "(4x 1/4 inch-20 SHCS with lock washers) through bulkhead, " - "finger-tight only." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Check Clearances", - "instructions": ( - "Verify igniter leads, nozzle exit, and propellant feed ports are " - "not fouled. Minimum 0.125 inch clearance to airframe ID." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Aft Section — Final Install and Torque", - "instructions": "Final-torque all aft section fasteners. Apply witness marks.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Torque Aft Bulkhead Retaining Ring", - "instructions": ( - "Torque retaining ring to 60 in-lb using spanner wrench. Verify even " - "contact around full circumference." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Apply Witness Marks", - "instructions": ( - "Apply torque-seal (yellow) to all aft section fastener heads. " - "Photograph from two angles." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Torque Engine Mount Bolts", - "instructions": ( - "Torque 4x 1/4 inch-20 engine mount bolts in star pattern to 120 " - "in-lb. Apply Loctite 242 per drawing." - ), - "duration": 15, - "workcenter": "CLEAN", - "requires_signoff": True, - "schema": { - "fields": [ - { - "name": f"bolt{i}_torque", - "type": "number", - "label": f"Bolt {i} torque (in-lb)", - "required": True, - } - for i in range(1, 5) - ] - }, - }, - ], - }, - { - "title": "Propellant Tank Installation", - "instructions": ( - "Install LOX and fuel tanks onto thrust structure. Connect feedlines and " - "vent lines." - ), - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Install LOX Tank", - "instructions": ( - "Lower LOX tank (KST-F-0006) onto thrust structure standoffs. Orient " - "fill port to 0 degree clock position. Install 4x 10-32 SHCS " - "finger-tight." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Install Fuel Tank", - "instructions": ( - "Stack fuel tank (KST-F-0007) above LOX tank on spacer ring. Orient " - "fill port to 180 degree clock position. Install 4x 10-32 SHCS " - "finger-tight." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Torque Tank Mount Bolts", - "instructions": ( - "Torque all 8x 10-32 tank mount SHCS to 40 in-lb in alternating pattern." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Connect Propellant Feedlines", - "instructions": ( - "Connect 1/2 inch SS feedlines from tank outlets to engine inlets. " - "Install AN fittings (KST-F-0015). Torque AN-8 fittings to 180 in-lb." - ), - "duration": 15, - "workcenter": "CLEAN", - "requires_signoff": True, - "schema": { - "fields": [ - { - "name": "lox_fitting_torque", - "type": "number", - "label": "LOX feed fitting torque (in-lb)", - "required": True, - }, - { - "name": "fuel_fitting_torque", - "type": "number", - "label": "Fuel feed fitting torque (in-lb)", - "required": True, - }, - ] - }, - }, - { - "title": "Connect Vent Lines", - "instructions": ( - "Route 1/4 inch vent lines from tank ullage ports to forward " - "bulkhead feedthroughs. Install AN-4 fittings. Torque to 80 in-lb." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Pneumatic Leak Check", - "instructions": ( - "Pressure-test all propellant and pneumatic connections with GN2 before " - "closing out the aft section." - ), - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Cap Open Ports", - "instructions": ( - "Install test caps on engine inlet ports and vent line " - "terminations. Verify all caps are seated." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Pressurize to 50 PSI", - "instructions": ( - "Connect GN2 supply via test fixture (KST-G-0005). Slowly pressurize " - "propellant circuit to 50 PSI. Hold 60 seconds." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Soap-Bubble Inspect All Joints", - "instructions": ( - "Apply Snoop leak detector to every fitting, O-ring face, and " - "feedthrough. No bubbles permitted." - ), - "duration": 15, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Depressurize and Remove Caps", - "instructions": ( - "Slowly vent to 0 PSI. Remove all test caps. Disconnect GN2 supply." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Forward Bulkhead and Pressurant Routing", - "instructions": ( - "Install forward bulkhead. Route recovery harness, vent lines, and " - "pressurant feed through bulkhead penetrations." - ), - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Install Forward Bulkhead O-Rings", - "instructions": ( - "Lubricate 2x -016 O-rings with Krytox grease. Seat into forward " - "bulkhead (KST-F-0021) grooves." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Seat Forward Bulkhead", - "instructions": ( - "Insert forward bulkhead into airframe tube. Align wire feedthrough " - "to 90 degree clock position. Press until O-rings engage." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Route Vent Lines Through Bulkhead", - "instructions": ( - "Pass LOX and fuel vent lines through bulkhead AN fittings. Tighten " - "to 80 in-lb." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Install Bulkhead Retaining Ring", - "instructions": "Install and torque forward bulkhead retaining ring to 60 in-lb.", - "duration": 5, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Route Recovery Harness", - "instructions": ( - "Thread shock cord (KST-F-0035) through center feedthrough. Leave " - "24 inches of slack on recovery bay side." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Avionics Bay Integration", - "instructions": "Install avionics sled into coupler section. Make all electrical connections.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Slide Avionics Sled into Coupler", - "instructions": ( - "Insert avionics sled assembly into coupler tube (KST-F-0023). " - "Align mounting rails. Secure with 4x 10-32 SHCS." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Connect Pyro Leads", - "instructions": ( - "Attach drogue pyro channel leads to forward separation joint " - "e-match terminals. Attach main pyro channel leads to aft separation " - "joint e-match terminals. Verify correct polarity per wiring diagram." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Connect Telemetry Antenna", - "instructions": ( - "Route antenna coax from RFM95W to SMA bulkhead on coupler. Torque " - "SMA connector to 5 in-lb." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Connect Umbilical", - "instructions": ( - "Route external umbilical connector (arming plug, charge cable) " - "through coupler access port. Verify connector seats fully." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Power-On Verification", - "instructions": ( - "Connect battery (KST-F-0030). Verify flight computer boots, GPS " - "acquires, IMU initializes, telemetry transmits test packet to ground " - "station. Verify both pyro channels show continuity." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Power Off and Safe", - "instructions": ( - "Power down flight computer. Disconnect battery. Install arming plug " - "safety cap." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Recovery Bay Pack", - "instructions": "Pack parachutes into recovery section. Install ejection charges. Connect shock cords.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Attach Shock Cord to Forward U-Bolts", - "instructions": ( - "Tie shock cord to 2x U-bolts (KST-F-0036) on forward bulkhead using " - "double-bowline knot. Apply 50 lbf pull test to each attachment." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Pack Drogue Parachute", - "instructions": ( - "Place folded drogue (KST-F-0034) in deployment bag. Attach " - "deployment bag to shock cord with lark's head knot. Position drogue " - "at aft end of recovery bay." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Pack Main Parachute", - "instructions": ( - "Place folded main chute (KST-F-0033) in deployment bag. Attach to " - "shock cord forward of drogue. Tuck deployment bag and shroud lines " - "neatly." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Install Ejection Charges", - "instructions": ( - "Load 2.5g FFFFg black powder into drogue charge well. Load 4.0g " - "into main charge well. Install e-matches (KST-D-0038) into each " - "well. Tape leads along shock cord." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Verify Charge Continuity", - "instructions": ( - "Using multimeter, verify continuity on both e-match circuits " - "before final close-out." - ), - "duration": 5, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - ], - }, - { - "title": "Section Mating and Shear Pins", - "instructions": "Mate all airframe sections. Install shear pins at separation joints.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Install Aft Separation Joint Shear Pins", - "instructions": ( - "Install 3x nylon shear pins (KST-F-0037) at 120 degree spacing " - "through airframe and coupler at aft separation joint. Pins should " - "sit flush." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Install Forward Separation Joint Shear Pins", - "instructions": ( - "Install 3x nylon shear pins at 120 degree spacing through recovery " - "tube and coupler at forward separation joint." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Install Nose Cone", - "instructions": ( - "Seat nose cone (KST-F-0019) onto recovery tube shoulder. Secure " - "with 2x nylon shear pins." - ), - "duration": 5, - "workcenter": "CLEAN", - }, - { - "title": "Verify All Sections Seated", - "instructions": ( - "Visually confirm all coupler joints are fully engaged. No gaps at " - "any joint. Run a straight edge along airframe to check alignment." - ), - "duration": 5, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Mate Avionics Bay to Propulsion Section", - "instructions": ( - "Slide coupler section into propulsion airframe tube. Align antenna " - "port to 270 degrees clock position. Engage coupler 4 inches into " - "tube." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Mate Recovery Bay to Avionics Section", - "instructions": ( - "Slide recovery bay airframe onto upper coupler. Engage coupler 4 " - "inches into recovery tube." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Fin and Rail Button Installation", - "instructions": "Bond fins to airframe. Install rail buttons at CG and CP stations.", - "duration": 5, - "workcenter": "SHOP", - "sub_steps": [ - { - "title": "Mark Fin Locations", - "instructions": ( - "Using fin alignment jig, mark 3x fin root locations at 120 degree " - "spacing on aft airframe tube. Mark leading and trailing edge " - "references." - ), - "duration": 10, - "workcenter": "SHOP", - }, - { - "title": "Surface Prep for Bonding", - "instructions": ( - "Sand fin root edges and airframe bonding areas with 220-grit. " - "Clean with acetone. Allow to dry 10 minutes." - ), - "duration": 15, - "workcenter": "SHOP", - }, - { - "title": "Bond Fins", - "instructions": ( - "Mix 30-minute epoxy per manufacturer instructions. Apply fillet to " - "fin root. Press fin onto airframe in jig. Repeat for all 3 fins. " - "Allow cure per epoxy data sheet (minimum 12 hours)." - ), - "duration": 20, - "workcenter": "SHOP", - "requires_signoff": True, - }, - { - "title": "Apply Fin Fillets", - "instructions": ( - "After initial cure, apply secondary epoxy fillets on both sides of " - "each fin root. Smooth with gloved finger. Allow full cure." - ), - "duration": 15, - "workcenter": "SHOP", - }, - { - "title": "Install Rail Buttons", - "instructions": ( - "Install 2x rail buttons (KST-F-0024) with 8-32 SHCS. Lower button " - "at predicted CG station. Upper button at predicted CP station. " - "Torque to 15 in-lb." - ), - "duration": 10, - "workcenter": "SHOP", - }, - ], - }, - { - "title": "Safety Wire and Lockout", - "instructions": "Install safety wire on all critical fasteners. Verify all locking features are in place.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Safety Wire AN Propellant Fittings", - "instructions": ( - "Install safety wire on LOX and fuel AN feed fittings. Wire to " - "adjacent structure attach point." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Verify All Lock Washers Compressed", - "instructions": ( - "Inspect every lock washer in aft section. All must show visible " - "compression (flat, no spring gap)." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Photograph Safety Wire", - "instructions": "Take close-up photos of all safety wire runs. Include in build traveler.", - "duration": 5, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Safety Wire Engine Mount Bolts", - "instructions": ( - "Install 0.032 inch MS20995C stainless safety wire on engine mount " - "bolt pairs in positive-lock direction. Verify 6 to 8 twists per inch." - ), - "duration": 15, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Final Mass Properties and Inspection", - "instructions": ( - "Weigh completed vehicle. Measure CG. Verify stability margin. Perform " - "final visual inspection." - ), - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Weigh Vehicle", - "instructions": ( - "Place completed vehicle on calibrated scale. Record dry mass. " - "Compare to predicted mass, must be within plus or minus 5%." - ), - "duration": 5, - "workcenter": "CLEAN", - "schema": { - "fields": [ - { - "name": "dry_mass_lb", - "type": "number", - "label": "Dry mass (lb)", - "required": True, - } - ] - }, - }, - { - "title": "Measure CG Location", - "instructions": "Balance vehicle on knife-edge. Mark CG location. Measure from nose tip.", - "duration": 10, - "workcenter": "CLEAN", - "schema": { - "fields": [ - { - "name": "cg_from_nose_in", - "type": "number", - "label": "CG from nose tip (in)", - "required": True, - } - ] - }, - }, - { - "title": "Calculate Static Margin", - "instructions": ( - "Static margin = (CP - CG) / body diameter. Must be at least 1.5 " - "calibers for flight acceptance." - ), - "duration": 5, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Final Visual Inspection", - "instructions": ( - "Inspect entire vehicle exterior: no dents, cracks, loose fins, " - "protruding fasteners, or FOD. Verify all access ports closed. Check " - "paint/markings per drawing." - ), - "duration": 10, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Photograph Completed Vehicle", - "instructions": ( - "Take 6 photographs: fore, aft, left, right, top (fins), detail of " - "nose cone. Include scale reference and SN placard in frame." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - ], - }, - { - "title": "Closeout and Documentation", - "instructions": "Complete all build records. Transfer vehicle to storage or launch prep.", - "duration": 5, - "workcenter": "CLEAN", - "sub_steps": [ - { - "title": "Complete Build Traveler", - "instructions": ( - "Fill in all remaining fields on build traveler. Attach all data " - "sheets, photos, and test records. Verify no open items." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Log All Serial Numbers", - "instructions": ( - "Record serial/lot numbers for all installed components, O-rings, " - "fastener lots, and epoxy batch. Enter into OPAL inventory system." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - { - "title": "Vehicle Acceptance Sign-Off", - "instructions": ( - "Build lead and QA inspector sign off that vehicle is complete, all " - "steps passed, and vehicle is ready for launch operations." - ), - "duration": 5, - "workcenter": "CLEAN", - "requires_signoff": True, - }, - { - "title": "Transfer to Storage", - "instructions": ( - "Install protective nose cone cover and fin guards. Place vehicle " - "in padded transport case. Move to bonded storage area. Update OPAL " - "location." - ), - "duration": 10, - "workcenter": "CLEAN", - }, - ], - }, - ] - _add_op_tree(db, fvi.id, wc, _fvi_ops) - - return procs # --------------------------------------------------------------------------- -# Versions & Executions +# Procedures (build + publish) # --------------------------------------------------------------------------- -def _seed_versions_and_executions( +def _seed_procedures( db: Session, - procs: dict[str, MasterProcedure], -) -> None: - now = datetime.now(UTC) - - # Publish versions for active procedures - for proc_key in ("eng_build", "hydro", "hotfire", "recovery"): - proc = procs[proc_key] - steps = ( - db.query(ProcedureStep) - .filter( - ProcedureStep.procedure_id == proc.id, - ) - .order_by(ProcedureStep.order) - .all() - ) - - content = { - "procedure_name": proc.name, - "procedure_description": proc.description, - "steps": [ - { - "id": s.id, - "order": s.order, - "step_number": s.step_number, - "level": s.level, - "parent_step_id": s.parent_step_id, - "title": s.title, - "instructions": s.instructions, - "required_data_schema": s.required_data_schema, - "is_contingency": s.is_contingency, - "requires_signoff": s.requires_signoff, - "estimated_duration_minutes": s.estimated_duration_minutes, - "workcenter_id": s.workcenter_id, - "depends_on": [], - "step_kit": [], - } - for s in steps - ], - "kit_items": [ - {"part_id": k.part_id, "quantity_required": float(k.quantity_required)} - for k in db.query(Kit).filter(Kit.procedure_id == proc.id).all() - ], - "output_items": [ - {"part_id": o.part_id, "quantity_produced": float(o.quantity_produced)} - for o in db.query(ProcedureOutput) - .filter(ProcedureOutput.procedure_id == proc.id) - .all() - ], - } - - version = ProcedureVersion( - procedure_id=proc.id, - version_number=1, - content=content, + data: dict, + parts: dict[str, Part], + wc: dict[str, Workcenter], +) -> tuple[dict[str, MasterProcedure], dict[str, ProcedureVersion]]: + procs: dict[str, MasterProcedure] = {} + versions: dict[str, ProcedureVersion] = {} + + for pdata in data["procedures"]: + proc = MasterProcedure( + name=pdata["name"], + description=pdata["description"], + procedure_type=(ProcedureType.BUILD if pdata["type"] == "build" else ProcedureType.OP), + status=ProcedureStatus.ACTIVE, ) - db.add(version) + db.add(proc) db.flush() - proc.current_version_id = version.id + procs[pdata["key"]] = proc - # Create executions for hydro (completed) and hotfire (in-progress) - if proc_key == "hydro": - inst = ProcedureInstance( - procedure_id=proc.id, - version_id=version.id, - work_order_number=generate_work_order_number(db), - status=InstanceStatus.COMPLETED, - started_at=now - timedelta(days=10, hours=3), - completed_at=now - timedelta(days=10, hours=1), + for item in pdata.get("kit", []): + db.add( + Kit( + procedure_id=proc.id, + part_id=parts[item["part"]].id, + quantity_required=Decimal(str(item["qty"])), + ) ) - db.add(inst) - db.flush() - - # All steps completed - for s in content["steps"]: - se = StepExecution( - instance_id=inst.id, - step_number=s["order"], - step_number_str=s["step_number"], - level=s["level"], - status=StepStatus.SIGNED_OFF if s["requires_signoff"] else StepStatus.COMPLETED, - started_at=now - - timedelta(days=10, hours=3) - + timedelta(minutes=s["order"] * 8), - completed_at=now - - timedelta(days=10, hours=3) - + timedelta(minutes=s["order"] * 8 + 7), + if pdata.get("output"): + db.add( + ProcedureOutput( + procedure_id=proc.id, + part_id=parts[pdata["output"]["part"]].id, + quantity_produced=Decimal(str(pdata["output"]["qty"])), ) - if s["required_data_schema"] and s["step_number"] == "5.1": - se.data_captured = {"proof_pressure": 677, "hold_duration_s": 305} - elif s["required_data_schema"] and s["step_number"] == "6.2": - se.data_captured = { - "od_station_1": 6.001, - "od_station_2": 6.000, - "od_station_3": 6.001, - } - db.add(se) + ) + db.flush() - elif proc_key == "hotfire": - inst = ProcedureInstance( + order = 0 + normal_count = 0 + contingency_count = 0 + op_steps: dict[int, ProcedureStep] = {} + for op_idx, op in enumerate(pdata["ops"], start=1): + order += 1 + is_contingency = bool(op.get("is_contingency")) + # Contingency ops number their own C-series, like the step API does. + if is_contingency: + contingency_count += 1 + op_number = f"C{contingency_count}" + else: + normal_count += 1 + op_number = str(normal_count) + op_step = ProcedureStep( procedure_id=proc.id, - version_id=version.id, - work_order_number=generate_work_order_number(db), - status=InstanceStatus.IN_PROGRESS, - started_at=now - timedelta(hours=2), - priority=1, + order=order, + step_number=op_number, + level=0, + title=op["title"], + instructions=op.get("instructions"), + caution=op.get("caution"), + is_contingency=is_contingency, + strict_sequence=bool(op.get("strict_sequence")), + workcenter_id=wc[op["workcenter"]].id if op.get("workcenter") else None, ) - db.add(inst) + db.add(op_step) db.flush() - - # Pre-test planning, engine install, and plumbing complete (orders 1-12). - # On instrumentation hookup (order 13). - for s in content["steps"]: - if s["order"] <= 12: - se = StepExecution( - instance_id=inst.id, - step_number=s["order"], - step_number_str=s["step_number"], - level=s["level"], - status=StepStatus.SIGNED_OFF - if s["requires_signoff"] - else StepStatus.COMPLETED, - started_at=now - timedelta(hours=2) + timedelta(minutes=s["order"] * 5), - completed_at=now - - timedelta(hours=2) - + timedelta(minutes=s["order"] * 5 + 4), + op_steps[op_idx] = op_step + for item in op.get("step_kit", []): + part = parts[item["part"]] + db.add( + StepKit( + step_id=op_step.id, + part_id=part.id, + quantity_required=Decimal(str(item["qty"])), + usage_type=UsageType.TOOLING if part.is_tooling else UsageType.CONSUME, ) - db.add(se) - elif s["order"] == 13: - se = StepExecution( - instance_id=inst.id, - step_number=s["order"], - step_number_str=s["step_number"], - level=s["level"], - status=StepStatus.IN_PROGRESS, - started_at=now - timedelta(minutes=15), + ) + for sub_idx, sub in enumerate(op.get("sub_steps", []), start=1): + order += 1 + db.add( + ProcedureStep( + procedure_id=proc.id, + parent_step_id=op_step.id, + order=order, + step_number=f"{op_number}.{sub_idx}", + level=1, + title=sub["title"], + instructions=sub.get("instructions"), + caution=sub.get("caution"), + is_contingency=is_contingency, + requires_signoff=bool(sub.get("requires_signoff")), + required_role=sub.get("required_role"), + required_data_schema=sub.get("schema"), + workcenter_id=wc[op["workcenter"]].id if op.get("workcenter") else None, ) - db.add(se) - else: - se = StepExecution( - instance_id=inst.id, - step_number=s["order"], - step_number_str=s["step_number"], - level=s["level"], - status=StepStatus.PENDING, + ) + # Op-level prerequisites (JSON lists prereq op indices, 1-based). + for op_idx, op in enumerate(pdata["ops"], start=1): + for dep_idx in op.get("depends_on", []): + db.add( + StepDependency( + step_id=op_steps[op_idx].id, + depends_on_step_id=op_steps[dep_idx].id, ) - db.add(se) - + ) + db.flush() + versions[pdata["key"]] = _publish(db, proc) + + return procs, versions + + +def _publish(db: Session, proc: MasterProcedure) -> ProcedureVersion: + """Snapshot the procedure exactly like the publish endpoint does.""" + steps = ( + db.query(ProcedureStep) + .filter(ProcedureStep.procedure_id == proc.id) + .order_by(ProcedureStep.order) + .all() + ) + step_ids = [s.id for s in steps] + step_kit_map: dict[int, list[StepKit]] = {} + for sk in db.query(StepKit).filter(StepKit.step_id.in_(step_ids)).all(): + step_kit_map.setdefault(sk.step_id, []).append(sk) + + # Dependencies snapshot as prerequisite `order` values (mirrors the + # publish endpoint) so the execution engine gates ops from the frozen copy. + step_id_to_order = {s.id: s.order for s in steps} + depends_on_map: dict[int, list[int]] = {} + for dep in db.query(StepDependency).filter(StepDependency.step_id.in_(step_ids)).all(): + prereq_order = step_id_to_order.get(dep.depends_on_step_id) + if prereq_order is not None: + depends_on_map.setdefault(dep.step_id, []).append(prereq_order) + + content = { + "procedure_name": proc.name, + "procedure_description": proc.description, + "steps": [ + { + "id": s.id, + "order": s.order, + "step_number": s.step_number, + "level": s.level, + "parent_step_id": s.parent_step_id, + "title": s.title, + "instructions": s.instructions, + "required_data_schema": s.required_data_schema, + "is_contingency": s.is_contingency, + "requires_signoff": s.requires_signoff, + "estimated_duration_minutes": s.estimated_duration_minutes, + "required_role": s.required_role, + "caution": s.caution, + "strict_sequence": s.strict_sequence, + "workcenter_id": s.workcenter_id, + "depends_on": sorted(depends_on_map.get(s.id, [])), + "images": [], + "step_kit": [ + { + "part_id": sk.part_id, + "part_name": sk.part.name, + "quantity_required": float(sk.quantity_required), + "usage_type": sk.usage_type.value + if hasattr(sk.usage_type, "value") + else sk.usage_type, + "notes": sk.notes, + } + for sk in step_kit_map.get(s.id, []) + ], + } + for s in steps + ], + "kit_items": [ + {"part_id": k.part_id, "quantity_required": float(k.quantity_required)} + for k in db.query(Kit).filter(Kit.procedure_id == proc.id).all() + ], + "output_items": [ + {"part_id": o.part_id, "quantity_produced": float(o.quantity_produced)} + for o in db.query(ProcedureOutput).filter(ProcedureOutput.procedure_id == proc.id).all() + ], + } + version = ProcedureVersion(procedure_id=proc.id, version_number=1, content=content) + db.add(version) db.flush() + proc.current_version_id = version.id + return version # --------------------------------------------------------------------------- -# Purchases +# Purchases & stock plan # --------------------------------------------------------------------------- +_PACK_RE = re.compile(r"[Pp]ack of (\d+)") + + +def _pack_size(entry: dict) -> int: + """Units per orderable pack, where the catalog name declares one.""" + if entry.get("uom") != "pack": + return 1 + match = _PACK_RE.search(entry["name"]) + return int(match.group(1)) if match else 1 + + +def _stock_plan(parts_data: dict, procs_data: dict) -> dict[str, int]: + """Intended on-hand stock per part key — the one derivation that both the + purchase orders and the shelf records agree on. + + Kit demand across all published procedures drives the plan: bulk parts + hold three kits' worth (minimum 4), rounded up to whole packs where the + catalog sells packs; serialized parts hold exactly the open kit demand + (one unit-quantity record each). + """ + need: dict[str, float] = {} + for proc in procs_data["procedures"]: + for item in proc.get("kit", []): + need[item["part"]] = need.get(item["part"], 0) + item["qty"] + + plan: dict[str, int] = {} + for entry in parts_data["parts"]: + key = entry["key"] + if key in _NO_STOCK or entry["lifecycle"] == "draft": + continue + if entry["tracking"] == "serialized": + plan[key] = max(int(need.get(key, 0)), 1) + else: + qty = max(int(need.get(key, 0)) * 3, 4) + pack = _pack_size(entry) + plan[key] = -(-qty // pack) * pack # round up to whole packs + return plan + + +# PO-0001: the big McMaster kit order, fully received. +_PO1_LINES = [ + "9452k226", + "91255a378", + "94579a550", + "91306a279", + "90264a435", + "91367a952", + "4112t22", + "91280a044", + "90591a141", + "98689a113", + "92148a160", + "93475a210", + "92855a310", + "90480a009", + "90272a194", + "4468k858", + "4468k031", + "4468k865", + "44665k137", + "50675k135", + "50675k435", + "9685t3", + "9396t31", + "9452k15", + "90213a101", + "94095k114", + "9396k79", + "92141a029", + "91102a750", + "95505a601", + "90281a102", + "93135a013", + "93505a211", + "7712k511", + "8947t26", + "52555t644", + "7619a11", +] + +# PO-0002: spares per the maintenance schedule, partially received. +# (received?, key, packs ordered) — quantities are authored in orderable +# packs and expanded to stock units when the line is written. O-ring and +# gasket spares arrived; the rest is backordered. +_PO2_LINES = [ + (True, "94095k114", 2), + (True, "9452k15", 1), + (False, "9452k226", 1), + (False, "90281a102", 2), + (False, "98831a028", 2), +] + def _seed_purchases( db: Session, - p: dict[str, Part], + parts_data: dict, + parts: dict[str, Part], suppliers: dict[str, Supplier], -) -> None: - now = datetime.now(UTC) + plan: dict[str, int], + now: datetime, +) -> dict[str, PurchaseLine]: + """Seed the two McMaster orders. + + PO-0001 quantities ARE the stock plan: the kit order bought exactly what + the shelf holds, so every stock record's source line accounts for it. + Costs are per stock unit — pack prices are divided by the pack size the + catalog name declares. + """ + prices = {p["key"]: p.get("price") for p in parts_data["parts"]} + packs = {p["key"]: _pack_size(p) for p in parts_data["parts"]} + + def unit_cost(key: str) -> Decimal | None: + if not prices.get(key): + return None + return (Decimal(str(prices[key])) / packs[key]).quantize(Decimal("0.0001")) + + po_lines: dict[str, PurchaseLine] = {} - # PO-0001 McMaster — received po1 = Purchase( reference="PO-0001", supplier="McMaster-Carr", supplier_id=suppliers["McMaster-Carr"].id, status=PurchaseStatus.RECEIVED, - ordered_at=now - timedelta(days=30), - received_at=now - timedelta(days=27), + ordered_at=now - timedelta(days=34), + received_at=now - timedelta(days=30), destination="STORE", - notes="Initial fastener and plumbing stock-up", + notes="Vehicle build kit — Appendix F McMaster order.", ) db.add(po1) db.flush() - for part_key, qty, cost in [ - ("shcs_quarter", 200, Decimal("0.18")), - ("shcs_10_32", 150, Decimal("0.14")), - ("hex_nut_quarter", 200, Decimal("0.08")), - ("lock_washer_quarter", 200, Decimal("0.06")), - ("oring_012", 50, Decimal("0.45")), - ("oring_016", 50, Decimal("0.52")), - ("oring_116", 25, Decimal("1.85")), - ("tube_half", 24, Decimal("8.50")), - ("tube_quarter", 18, Decimal("5.20")), - ("an_fitting_half", 12, Decimal("12.40")), - ("an_fitting_quarter", 16, Decimal("8.90")), - ("shear_pin", 100, Decimal("0.03")), - ("rail_button", 8, Decimal("2.15")), - ("ubolt", 6, Decimal("3.80")), - ("teflon_tape", 5, Decimal("2.50")), - ]: - db.add( - PurchaseLine( - purchase_id=po1.id, - part_id=p[part_key].id, - qty_ordered=Decimal(str(qty)), - qty_received=Decimal(str(qty)), - unit_cost=cost, - ) + for key in _PO1_LINES: + qty = Decimal(plan[key]) + line = PurchaseLine( + purchase_id=po1.id, + part_id=parts[key].id, + qty_ordered=qty, + qty_received=qty, + unit_cost=unit_cost(key), ) + db.add(line) + po_lines[key] = line - # PO-0002 Swagelok — partial (QD outstanding) po2 = Purchase( reference="PO-0002", - supplier="Swagelok", - supplier_id=suppliers["Swagelok"].id, + supplier="McMaster-Carr", + supplier_id=suppliers["McMaster-Carr"].id, status=PurchaseStatus.PARTIAL, - ordered_at=now - timedelta(days=45), - target_date=(now + timedelta(days=7)).date(), - destination="STORE-B1", - notes="Valves and fittings. QD on backorder.", + ordered_at=now - timedelta(days=9), + target_date=(now + timedelta(days=6)).date(), + destination="STORE", + notes="Maintenance spares: tank O-rings, graphite gaskets, QD seals, studs.", ) db.add(po2) db.flush() - db.add( - PurchaseLine( - purchase_id=po2.id, - part_id=p["lox_valve"].id, - qty_ordered=Decimal("1"), - qty_received=Decimal("1"), - unit_cost=Decimal("245.00"), - ) - ) - db.add( - PurchaseLine( - purchase_id=po2.id, - part_id=p["fuel_valve"].id, - qty_ordered=Decimal("1"), - qty_received=Decimal("1"), - unit_cost=Decimal("245.00"), - ) - ) - db.add( - PurchaseLine( - purchase_id=po2.id, - part_id=p["check_valve"].id, - qty_ordered=Decimal("4"), - qty_received=Decimal("4"), - unit_cost=Decimal("68.00"), - ) - ) - db.add( - PurchaseLine( - purchase_id=po2.id, - part_id=p["umbilical_qd"].id, - qty_ordered=Decimal("2"), - qty_received=Decimal("0"), - unit_cost=Decimal("185.00"), - notes="Backordered — expected ship date 2026-03-28", - ) - ) - db.add( - PurchaseLine( - purchase_id=po2.id, - part_id=p["ground_reg"].id, - qty_ordered=Decimal("1"), - qty_received=Decimal("1"), - unit_cost=Decimal("420.00"), + for received, key, pack_qty in _PO2_LINES: + qty = Decimal(pack_qty * packs[key]) + db.add( + PurchaseLine( + purchase_id=po2.id, + part_id=parts[key].id, + qty_ordered=qty, + qty_received=qty if received else Decimal("0"), + unit_cost=unit_cost(key), + notes=None if received else "Backordered", + ) ) - ) + db.flush() + return po_lines - # PO-0003 Digi-Key — received - po3 = Purchase( - reference="PO-0003", - supplier="Digi-Key", - supplier_id=suppliers["Digi-Key"].id, - status=PurchaseStatus.RECEIVED, - ordered_at=now - timedelta(days=21), - received_at=now - timedelta(days=18), - destination="LAB", - ) - db.add(po3) + +# --------------------------------------------------------------------------- +# Inventory +# --------------------------------------------------------------------------- + +_LOCATION_BY_CATEGORY = { + "Propulsion": "STORE-A1", + "Fluid System": "STORE-A2", + "Valves": "STORE-A2", + "Igniter": "STORE-A3", + "Airframe": "STORE-B1", + "Recovery": "STORE-B2", + "Avionics": "ELEC-SHELF", + "Consumables": "STORE-C1", + "GSE": "PAD-BOX", +} + +# Assembly parents that exist as BOM structure, not as shelf stock. +_NO_STOCK = { + "vehicle", + "gse", + "tank_assy", + "fluid_assy", + "valve_assy_grp", + "tca_assy", + "igniter_assy", + "airframe_assy", + "recovery_assy", + "avbay_assy", + "gse_control", + "gse_fill", + "gse_enclosure", + "gse_integration", +} + +_LOT_PARTS = { + "9452k226": "LOT-MCM-2606-A", + "9452k15": "LOT-MCM-2606-A", + "9396k79": "LOT-MCM-2606-B", + "94095k114": "LOT-MCM-2606-B", + "1834": "LOT-CRS-2605-EM", + "1318": "LOT-CRS-2605-BP", + "a3_4t": "LOT-EST-2604", + "nitrous_oxide": "LOT-N2O-FILL-06", + "e85": "LOT-E85-2606", + "pla_fil_2kg": "LOT-PLA-2605", + "52555t644": "LOT-MCM-2606-C", +} + + +def _stock_location(part: Part) -> str: + procurement = part.procurement.value if hasattr(part.procurement, "value") else part.procurement + return ( + "SHOP-RACK" + if procurement == "make" + else _LOCATION_BY_CATEGORY.get(part.category, "STORE-A1") + ) + + +def _seed_inventory( + db: Session, + parts_data: dict, + parts: dict[str, Part], + po_lines: dict[str, PurchaseLine], + plan: dict[str, int], + now: datetime, +) -> dict[str, list[InventoryRecord]]: + """Put the planned stock on the shelf — quantities come from _stock_plan, + the same numbers the purchase orders received. + + Bulk parts hold one record at the planned quantity; serialized parts hold + one unit-quantity record per planned unit (serialized means one OPAL + number per physical unit), so every open kit line can pull real units. + """ + inventory: dict[str, list[InventoryRecord]] = {} + for entry in parts_data["parts"]: + key = entry["key"] + if key not in plan: + continue + part = parts[key] + if entry["tracking"] == "serialized": + count, qty = plan[key], Decimal("1") + if key == "sabv_assy": + count -= 1 # WO-4's freshly built valve supplies the third unit + else: + count, qty = 1, Decimal(plan[key]) + po_line = po_lines.get(key) + for _ in range(count): + rec = InventoryRecord( + part_id=part.id, + opal_number=generate_opal_number(db), + quantity=qty, + location=_stock_location(part), + lot_number=_LOT_PARTS.get(key), + source_type=SourceType.PURCHASE if po_line else SourceType.MANUAL, + source_purchase_line_id=po_line.id if po_line else None, + ) + db.add(rec) + inventory.setdefault(key, []).append(rec) db.flush() - for part_key, qty, cost in [ - ("fc", 2, Decimal("32.95")), - ("gps", 2, Decimal("18.50")), - ("imu", 2, Decimal("28.00")), - ("altimeter", 3, Decimal("12.40")), - ("radio", 2, Decimal("15.95")), - ("lipo", 4, Decimal("14.00")), - ("pyro_board", 2, Decimal("8.50")), - ]: + return inventory + + +# --------------------------------------------------------------------------- +# Executions — the live demo state +# --------------------------------------------------------------------------- + + +def _cut_instance( + db: Session, + proc: MasterProcedure, + version: ProcedureVersion, + status: InstanceStatus, + started_by: User | None, + started_at: datetime | None = None, + completed_at: datetime | None = None, +) -> tuple[ProcedureInstance, dict[str, StepExecution]]: + """Create an instance with one StepExecution per snapshot step (all PENDING). + + Returns the instance and a step_number_str -> StepExecution map. + """ + inst = ProcedureInstance( + procedure_id=proc.id, + version_id=version.id, + work_order_number=generate_work_order_number(db), + status=status, + started_by_id=started_by.id if started_by else None, + started_at=started_at, + completed_at=completed_at, + ) + db.add(inst) + db.flush() + + steps = version.content["steps"] + id_to_order = {s["id"]: s["order"] for s in steps} + by_number: dict[str, StepExecution] = {} + for s in steps: + se = StepExecution( + instance_id=inst.id, + step_number=s["order"], + step_number_str=s["step_number"], + level=s["level"], + parent_step_order=id_to_order.get(s["parent_step_id"]), + status=StepStatus.PENDING, + ) + db.add(se) + by_number[s["step_number"]] = se + db.flush() + return inst, by_number + + +def _complete_step( + se: StepExecution, + step: dict, + at: datetime, + user: User, + data: dict | None = None, +) -> None: + se.status = StepStatus.SIGNED_OFF if step["requires_signoff"] else StepStatus.COMPLETED + se.started_at = at + se.completed_at = at + timedelta(minutes=9) + se.completed_by_id = user.id + if step["requires_signoff"]: + se.signed_off_at = se.completed_at + se.signed_off_by_id = user.id + if data is not None: + se.data_captured = data + + +# Captured values for WO-1 (verbatim spec values from the guidebook steps). +_WO1_DATA = { + "1.1": {"ends_square": "Square against machinist square — no light gap either end"}, + "5.4": {"piston_depth": 12.19}, + "5.9": {"fuel_hose_union_torque": 80}, + "5.10": {"fuel_hose_valve_torque": 80}, + "10.9": {"verified_7x_91306a279_screws_install": "All 7 install and thread freely"}, + "10.10": {"verified_8x_90272a194_screws_install": "8x aligned; simulated install OK"}, + "10.11": {"verified_4x_93135a013_shear_pins_seat": "4x seat flush through both tubes"}, +} + +_WO1_NOTES = [ + ( + "1.4", + "test", + timedelta(days=15, hours=6), + "Drill jig bushing (93320A215) snug after the second hole — re-clamped and " + "finished the pattern. All 8x 5/16 in holes gauge clean.", + ), + ( + "5.4", + "build", + timedelta(days=15, hours=2), + "Piston seated at 12.19 in from the tube end on the marked PVC pusher — " + "inside the 12.125-12.25 in window on the first push.", + ), + ( + "5.9", + "build", + timedelta(days=15, hours=1), + "50675K135 union pulled to wrench-tight, call it 80 ft-lb per the step. Flare faces clean.", + ), + ( + "8.8", + "test", + timedelta(days=14, hours=20), + "Both Quarks beep out continuity on drogue and main channels with the 9V packs installed.", + ), + ( + "11.20", + "qa", + timedelta(days=14, hours=3), + "Final stack check done — shear pins flush, rail buttons clear the 1515 " + "test rail section. Vehicle to transport crate as SN 001.", + ), +] + + +def _consume_kit( + db: Session, + wo: ProcedureInstance, + kit_items: list[Kit], + parts: dict[str, Part], + part_key_by_id: dict[int, str], + inventory: dict[str, list[InventoryRecord]], + user: User, +) -> None: + """Consume a completed WO's kit from stock. + + Bulk lines draw down the shelf record. Serialized lines consume distinct + unit records created here — the physical units that went into the build — + so genealogy is complete while the shelf units stay for the open demand. + """ + for item in kit_items: + key = part_key_by_id[item.part_id] + part = parts[key] + tracking = part.tracking_type + tracking = tracking.value if hasattr(tracking, "value") else tracking + if tracking == "serialized": + for _ in range(int(item.quantity_required)): + rec = InventoryRecord( + part_id=part.id, + opal_number=generate_opal_number(db), + quantity=Decimal("0"), + location=_stock_location(part), + source_type=SourceType.MANUAL, + ) + db.add(rec) + db.flush() + db.add( + InventoryConsumption( + inventory_record_id=rec.id, + quantity=Decimal("1"), + procedure_instance_id=wo.id, + consumed_by_id=user.id, + ) + ) + else: + recs = inventory.get(key) + if not recs: + continue + rec = recs[0] + qty = min(item.quantity_required, rec.quantity) + rec.quantity -= qty + db.add( + InventoryConsumption( + inventory_record_id=rec.id, + quantity=qty, + procedure_instance_id=wo.id, + consumed_by_id=user.id, + ) + ) + + +def _seed_executions( + db: Session, + procs: dict[str, MasterProcedure], + versions: dict[str, ProcedureVersion], + parts: dict[str, Part], + inventory: dict[str, list[InventoryRecord]], + users: dict[str, User], + now: datetime, +) -> dict[str, Any]: + build, test, qa = users["build"], users["test"], users["qa"] + veh_proc, veh_version = procs["vehicle_assembly"], versions["vehicle_assembly"] + veh_steps = {s["step_number"]: s for s in veh_version.content["steps"]} + + # ── WO-1: Vehicle Assembly, COMPLETED ─────────────────────── + wo1_start = now - timedelta(days=16) + wo1, wo1_steps = _cut_instance( + db, + veh_proc, + veh_version, + InstanceStatus.COMPLETED, + build, + started_at=wo1_start, + completed_at=now - timedelta(days=14), + ) + t = wo1_start + for s in veh_version.content["steps"]: + se = wo1_steps[s["step_number"]] + user = (build, test, qa)[s["order"] % 3] + _complete_step(se, s, t, user, _WO1_DATA.get(s["step_number"])) + t += timedelta(minutes=20) + for step_num, username, ago, body in _WO1_NOTES: db.add( - PurchaseLine( - purchase_id=po3.id, - part_id=p[part_key].id, - qty_ordered=Decimal(str(qty)), - qty_received=Decimal(str(qty)), - unit_cost=cost, + StepNote( + step_execution_id=wo1_steps[step_num].id, + author_id=users[username].id, + body=body, + created_at=now - ago, ) ) - # PO-0004 Metal Supermarkets — ordered, awaiting - po4 = Purchase( - reference="PO-0004", - supplier="Metal Supermarkets", - supplier_id=suppliers["Metal Supermarkets"].id, - status=PurchaseStatus.ORDERED, - ordered_at=now - timedelta(days=5), - target_date=(now + timedelta(days=10)).date(), - destination="STORE-E1", - notes="Stock for tank and bulkhead fabrication", - ) - db.add(po4) + # Consume the vehicle kit (WO-1 built SN 001): bulk from the shelf, + # serialized as the distinct units that went into the vehicle. + kit_items = db.query(Kit).filter(Kit.procedure_id == veh_proc.id).all() + part_key_by_id = {p.id: k for k, p in parts.items()} + _consume_kit(db, wo1, kit_items, parts, part_key_by_id, inventory, build) + + # Produced vehicle SN 001. + veh_part = parts["vehicle"] + veh_rec = InventoryRecord( + part_id=veh_part.id, + quantity=Decimal("1"), + location="TRANSPORT-CRATE", + lot_number=wo1.work_order_number, + opal_number=generate_opal_number(db), + source_type=SourceType.PRODUCTION, + ) + db.add(veh_rec) + db.flush() + production = InventoryProduction( + inventory_record_id=veh_rec.id, + quantity=Decimal("1"), + procedure_instance_id=wo1.id, + serial_number=generate_serial_number(db, veh_part), + produced_opal_number=veh_rec.opal_number, + status=ProductionStatus.COMPLETED, + produced_by_id=build.id, + ) + db.add(production) + db.flush() + veh_rec.source_production_id = production.id + + # ── WO-2: Vehicle Assembly, IN WORK mid-OP-5 (tank assembly) ─ + wo2_start = now - timedelta(hours=26) + wo2, wo2_steps = _cut_instance( + db, veh_proc, veh_version, InstanceStatus.IN_WORK, build, started_at=wo2_start + ) + # SN 002 allocation, planned until closeout. + wo2_rec = InventoryRecord( + part_id=veh_part.id, + quantity=Decimal("0"), + location="", + lot_number=wo2.work_order_number, + opal_number=generate_opal_number(db), + source_type=SourceType.PRODUCTION, + ) + db.add(wo2_rec) + db.flush() + wo2_prod = InventoryProduction( + inventory_record_id=wo2_rec.id, + quantity=Decimal("1"), + procedure_instance_id=wo2.id, + serial_number=generate_serial_number(db, veh_part), + produced_opal_number=wo2_rec.opal_number, + status=ProductionStatus.WIP, + produced_by_id=build.id, + ) + db.add(wo2_prod) db.flush() + wo2_rec.source_production_id = wo2_prod.id + + t = wo2_start + for s in veh_version.content["steps"]: + num = s["step_number"] + se = wo2_steps[num] + op = int(num.split(".")[0]) + # OPs 1-4 fully complete; OP 5 (tank assembly) is mid-flight (its op + # row stays PENDING until the op completes under the focus model). + done = op <= 4 or num in ("5.1", "5.2", "5.3") + if done: + user = (build, test)[s["order"] % 2] + _complete_step( + se, + s, + t, + user, + {"ends_square": "Square — checked both ends"} if num == "1.1" else None, + ) + t += timedelta(minutes=11) + # The crew's position is presence (cursor), not status — the step ahead + # of the completed work stays PENDING with a focus row. + cursor = wo2_steps["5.4"] + cursor.first_focused_at = now - timedelta(minutes=45) db.add( - PurchaseLine( - purchase_id=po4.id, - part_id=p["al_plate"].id, - qty_ordered=Decimal("8"), - qty_received=Decimal("0"), - unit_cost=Decimal("45.00"), + StepFocus( + instance_id=wo2.id, + step_execution_id=cursor.id, + user_id=build.id, + focused_at=now - timedelta(minutes=12), ) ) db.add( - PurchaseLine( - purchase_id=po4.id, - part_id=p["ss_sheet"].id, - qty_ordered=Decimal("4"), - qty_received=Decimal("0"), - unit_cost=Decimal("62.00"), + StepNote( + step_execution_id=wo2_steps["5.1"].id, + author_id=build.id, + body="Step text calls for QTY 5 '-230' O-rings; the OP 5 kit is 4x " + "9452K226 -238 (2x bulkheads, 2x piston). Greased the four -238s.", + created_at=now - timedelta(hours=1, minutes=30), ) ) db.add( - PurchaseLine( - purchase_id=po4.id, - part_id=p["al_round"].id, - qty_ordered=Decimal("6"), - qty_received=Decimal("0"), - unit_cost=Decimal("38.00"), + StepNote( + step_execution_id=cursor.id, + author_id=test.id, + body="Holding before piston insertion until the O-ring callout NC is " + "dispositioned — seals are inaccessible after this step.", + created_at=now - timedelta(minutes=40), ) ) + # ── WO-3: Launch Operations, CUT (staged, untouched) ──────── + wo3, _wo3_steps = _cut_instance( + db, procs["launch_ops"], versions["launch_ops"], InstanceStatus.CUT, build + ) + + # ── WO-4: Servo-Actuated Ball Valve Assembly, COMPLETED ───── + # The GSE fill valve build — shows the component flow end to end: + # kit consumption in, serialized assembly out. + valve_proc, valve_version = procs["valve_assembly"], versions["valve_assembly"] + wo4_start = now - timedelta(days=3, hours=5) + wo4, wo4_steps = _cut_instance( + db, + valve_proc, + valve_version, + InstanceStatus.COMPLETED, + test, + started_at=wo4_start, + completed_at=now - timedelta(days=3, hours=3), + ) + t = wo4_start + for s in valve_version.content["steps"]: + _complete_step(wo4_steps[s["step_number"]], s, t, test) + t += timedelta(minutes=13) + valve_kit = db.query(Kit).filter(Kit.procedure_id == valve_proc.id).all() + _consume_kit(db, wo4, valve_kit, parts, part_key_by_id, inventory, test) + valve_part = parts["sabv_assy"] + valve_rec = InventoryRecord( + part_id=valve_part.id, + quantity=Decimal("1"), + location="STORE-A2", + lot_number=wo4.work_order_number, + opal_number=generate_opal_number(db), + source_type=SourceType.PRODUCTION, + ) + db.add(valve_rec) + db.flush() + valve_prod = InventoryProduction( + inventory_record_id=valve_rec.id, + quantity=Decimal("1"), + procedure_instance_id=wo4.id, + serial_number=generate_serial_number(db, valve_part), + produced_opal_number=valve_rec.opal_number, + status=ProductionStatus.COMPLETED, + produced_by_id=test.id, + ) + db.add(valve_prod) + db.flush() + valve_rec.source_production_id = valve_prod.id + db.flush() + return { + "wo1": wo1, + "wo1_steps": wo1_steps, + "wo2": wo2, + "wo2_steps": wo2_steps, + "wo3": wo3, + "veh_steps": veh_steps, + } # --------------------------------------------------------------------------- -# Issues +# Issues — drawn from real guidebook errata (bom.json / ops meta.extraction_gaps) # --------------------------------------------------------------------------- def _seed_issues( db: Session, - p: dict[str, Part], + parts: dict[str, Part], procs: dict[str, MasterProcedure], + wo: dict[str, Any], + users: dict[str, User], + now: datetime, ) -> dict[str, Issue]: - """Create demo issues. Returns dict keyed by short name for cross-referencing.""" - # ── Non-Conformances ──────────────────────────────────────── - - nc1 = Issue( + build, test, qa = users["build"], users["test"], users["qa"] + issues: dict[str, Issue] = {} + + # 1. Undispositioned NC on WO-2 (STEP containment, resolve-by boundary). + # Real erratum: the tank-assembly step text (guidebook OP 6, now OP 5) + # calls the tank seals "-230" (QTY 5); the gather list and BOM say + # 4x 9452K226 -238. + nc_oring = Issue( issue_number=generate_issue_number(db), - title="Injector plate hole pattern out of tolerance", - description='During QA inspection, bolt circle measured 0.005" outside tolerance.', + title="OP 5 tank O-ring callout mismatch: '-230' x5 vs kit -238 x4", + description=( + "Raised during tank assembly on WO " + f"{wo['wo2'].work_order_number}. The published step text and the " + "kit disagree on both dash size and count; the installed seals " + "follow the kit. Disposition before the piston goes in — the " + "seals are unreachable afterwards." + ), issue_type=IssueType.NON_CONFORMANCE, - status=IssueStatus.INVESTIGATING, + status=IssueStatus.OPEN, priority=IssuePriority.HIGH, - part_id=p["injector"].id, - should_be='32x 0.040" holes on 1.500" bolt circle ±0.002"', - is_condition='Holes measured at 1.505" bolt circle — 0.005" outside tolerance on 3 of 4 quadrants', - ) - db.add(nc1) + containment=Containment.STEP, + containment_step_id=wo["wo2_steps"]["5.4"].id, + part_id=parts["9452k226"].id, + procedure_id=procs["vehicle_assembly"].id, + procedure_instance_id=wo["wo2"].id, + raised_step_id=wo["wo2_steps"]["5.2"].id, + raised_by_id=test.id, + assigned_to_id=build.id, + should_be="OP 5 gather list: 4x 9452K226 -238 Buna-N O-rings (2x bulkheads, 2x piston)", + actual="Steps 1-3 text: 'a liberal amount of grease to the 5x Buna-N O-rings (-230)' " + "— dash size and quantity disagree with the kit", + created_at=now - timedelta(hours=1, minutes=10), + ) + db.add(nc_oring) db.flush() - - nc2 = Issue( - issue_number=generate_issue_number(db), - title="LOX tank circumferential weld porosity", - description="Radiographic inspection revealed pores in weld at station 14.", - issue_type=IssueType.NON_CONFORMANCE, - status=IssueStatus.DISPOSITION_PENDING, - priority=IssuePriority.CRITICAL, - part_id=p["lox_tank"].id, - should_be="Full-penetration weld with no porosity per AWS D17.1 Class A", - is_condition="Three pores detected: 0.8 mm, 0.6 mm, 0.5 mm in circumferential weld at station 14. Total aggregate porosity exceeds Class A limit.", + db.add( + IssueComment( + issue_id=nc_oring.id, + body="Physical parts on the bench are -238 from LOT-MCM-2606-A and match " + "the tank gland drawings. Recommending USE AS IS with a doc redline " + "against the step text.", + created_at=now - timedelta(minutes=50), + ) ) - db.add(nc2) + issues["nc_oring"] = nc_oring - nc3 = Issue( + # 2. Advisory issue: airframe fabrication (guidebook OP 12, now OP 10) + # references a template missing from its gather list. + adv = Issue( issue_number=generate_issue_number(db), - title="Aft bulkhead O-ring groove surface finish", - description="Surface finish on aft bulkhead bore exceeds specification.", + title="OP 10 step 8 uses template TMPLT-AF-UPR2-416 absent from gather list", + description=( + "OP 10 step 8 calls out drill template TMPLT-AF-UPR2-416; the OP 10 " + "gather list only carries TMPLT-AF-UPR-416 and the backing plug. " + "Advisory — the on-hand template covers the hole pattern." + ), issue_type=IssueType.NON_CONFORMANCE, status=IssueStatus.OPEN, - priority=IssuePriority.MEDIUM, - part_id=p["bulkhead_aft"].id, - should_be="16 µin Ra max per AS568 gland specification for static radial seal", - is_condition="Profilometer measured 32 µin Ra on aft bulkhead bore — 2x allowable roughness", - ) - db.add(nc3) - - nc4 = Issue( + priority=IssuePriority.LOW, + containment=Containment.ADVISORY, + part_id=parts["tmplt_af_upr_416"].id, + procedure_id=procs["vehicle_assembly"].id, + procedure_instance_id=wo["wo2"].id, + raised_by_id=build.id, + created_at=now - timedelta(days=1), + ) + db.add(adv) + issues["adv_template"] = adv + + # 3. Dispositioned USE AS IS (signed) — Appendix F stud-count duplication. + stud = Issue( issue_number=generate_issue_number(db), - title="Pressurant regulator external leak at set pressure", - description="Bubble test shows leak at outlet fitting when regulator is at setpoint.", + title="Appendix F lists 16x 90281A102 studs; Section IV totals 14", + description=( + "Kitting for the first vehicle pulled 16 studs per Appendix F, which " + "lists 90281A102 on two lines (qty 14 + qty 2); Section IV totals 14 " + "(8x chamber tie rods + 6x airframe/coupler studs)." + ), issue_type=IssueType.NON_CONFORMANCE, status=IssueStatus.OPEN, - priority=IssuePriority.HIGH, - part_id=p["ground_reg"].id, - should_be="Regulate to 450 PSI ±10 PSI with zero external leakage", - is_condition="Bubble test shows steady stream at outlet NPT fitting at 400 PSI. Leak rate ~5 cc/min.", - ) - db.add(nc4) - - # ── Bugs ──────────────────────────────────────────────────── - - bug1 = Issue( - issue_number=generate_issue_number(db), - title="Flight computer resets during pyro channel firing", - description="FC reboots when e-match is fired on either pyro channel.", - issue_type=IssueType.BUG, - status=IssueStatus.INVESTIGATING, - priority=IssuePriority.HIGH, - part_id=p["fc"].id, - steps_to_reproduce="1. Power on flight computer via LiPo\n2. Arm both pyro channels via software command\n3. Fire channel 1 e-match\n4. Observe FC status LED and telemetry stream", - expected_behavior="FC maintains operation, logs firing event timestamp and channel ID, telemetry stream uninterrupted", - actual_behavior="FC resets to boot screen immediately on firing. Telemetry drops for 3 seconds. No firing event logged. Behavior is 100% reproducible on both channels.", - ) - db.add(bug1) - db.flush() - - bug2 = Issue( - issue_number=generate_issue_number(db), - title="GPS cold lock exceeds 5 min in vertical orientation", - description="GPS acquisition time dramatically worse when avionics sled is mounted vertically (flight orientation).", - issue_type=IssueType.BUG, - status=IssueStatus.OPEN, priority=IssuePriority.MEDIUM, - part_id=p["gps"].id, - steps_to_reproduce="1. Mount avionics sled in vertical orientation (flight config)\n2. Power on outdoors with clear sky view\n3. Monitor NMEA stream for fix quality indicator\n4. Record time to first 3D fix", - expected_behavior="GPS 3D lock within 60 seconds (horizontal baseline with same antenna: 15 seconds typical)", - actual_behavior="Lock takes 5-8 minutes in vertical orientation. Occasionally fails to acquire within 10-minute timeout. Suspect antenna ground plane effect.", + containment=Containment.STEP, + part_id=parts["90281a102"].id, + procedure_id=procs["vehicle_assembly"].id, + procedure_instance_id=wo["wo1"].id, + raised_step_id=wo["wo1_steps"]["7.1"].id, + containment_step_id=wo["wo1_steps"]["7.1"].id, + raised_by_id=qa.id, + should_be="Section IV usage: 8x TCA tie rods + 6x coupler/recovery studs = 14", + actual="Appendix F vehicle McMaster table carries 90281A102 twice (14 + 2 = 16)", + disposition_type=DispositionType.USE_AS_IS, + disposition_rationale=( + "Installed count verified against Section IV: 8 + 6 = 14. The Appendix F " + "second line is a duplication; the two extra studs go to spares stock." + ), + dispositioned_at=now - timedelta(days=15, hours=4), + dispositioned_by_id=qa.id, + created_at=now - timedelta(days=15, hours=6), ) - db.add(bug2) + db.add(stud) + issues["stud_count"] = stud - bug3 = Issue( + # 4. Closed NC — recovery screws PN/link discrepancy, corrected at receiving. + screws = Issue( issue_number=generate_issue_number(db), - title="Telemetry packet CRC errors above 500 m range", - description="Packet loss exceeds link budget prediction at relatively short range.", - issue_type=IssueType.BUG, - status=IssueStatus.OPEN, - priority=IssuePriority.MEDIUM, - part_id=p["radio"].id, - steps_to_reproduce="1. Set up ground station at pad with directional antenna\n2. Mount flight transmitter on drone\n3. Fly drone to 500 m slant range, then 1 km\n4. Log packet reception rate and CRC error count", - expected_behavior="<1% packet loss to 2 km slant range per link budget (SF7, BW125, +20 dBm, 6 dBi ground antenna)", - actual_behavior="12% CRC errors at 500 m, 40% at 1 km. Ground station RSSI suggests adequate signal — likely interference or impedance mismatch at SMA bulkhead.", - ) - db.add(bug3) - - # ── Tasks ─────────────────────────────────────────────────── - - task1 = Issue( + title="Recovery BOM cites 94735A707; link and Appendix F use 93135A013", + description=( + "Recovery System BOM line 14 prints PN 94735A707, but both the " + "Section IV link and the Appendix F order table point to 93135A013 " + "(2-56 nylon pan head screws)." + ), + issue_type=IssueType.NON_CONFORMANCE, + status=IssueStatus.CLOSED, + priority=IssuePriority.LOW, + containment=Containment.STEP, + part_id=parts["93135a013"].id, + raised_by_id=qa.id, + should_be="One part number per part — BOM line, link, and order table agree", + actual="BOM line prints 94735A707; the hardware received and both source " + "links are 93135A013", + root_cause="Typo on the printed BOM line; the ordering tables were correct.", + corrective_action="Part record normalized to 93135A013; received stock " + "verified against the McMaster label.", + disposition_type=DispositionType.NO_DEFECT, + disposition_rationale="Hardware on hand matches 93135A013 in both thread and " + "material; document-only error.", + dispositioned_at=now - timedelta(days=28), + dispositioned_by_id=build.id, + created_at=now - timedelta(days=29), + ) + db.add(screws) + issues["screws_pn"] = screws + + # 5. Field finding that realizes the recovery risk (maintenance trigger: + # insulated shock cord is replaced on tears or serious burns). + shock = Issue( issue_number=generate_issue_number(db), - title="Order replacement LOX-compatible O-rings (Viton -116)", - description="Current Buna-N -116 O-rings are not LOX-compatible. Need Viton (fluoroelastomer) replacements for all LOX-wetted seals.", - issue_type=IssueType.TASK, + title="Shock cord thermal damage found at recovery inspection", + description=( + "Post-flight inspection of SN 001 found burn-through of the insulated " + "section of the 5/8 in tubular nylon shock cord. Maintenance schedule " + "action: replace insulated shock cord on tears or serious burns — " + "lack of maintenance may cause airframe/propulsion separation." + ), + issue_type=IssueType.NON_CONFORMANCE, status=IssueStatus.OPEN, priority=IssuePriority.HIGH, - ) - db.add(task1) - - task2 = Issue( + containment=Containment.ADVISORY, + part_id=parts["rec_tnsc_3klb"].id, + raised_by_id=test.id, + assigned_to_id=build.id, + should_be="Insulated shock cord free of tears and burn damage", + actual="Burn-through of the Nomex-protected section adjacent to the drogue " + "charge well; nylon core intact but glazed", + created_at=now - timedelta(days=7), + ) + db.add(shock) + issues["shock_cord"] = shock + + # 6. Mitigation task for the N2O decomposition risk. + clean = Issue( issue_number=generate_issue_number(db), - title="Machine new injector plate with corrected hole pattern", - description='Remake injector from 304 SS stock with corrected bolt circle diameter (1.500" ±0.002"). Use CNC for hole pattern.', + title="Add contamination controls for N2O-wetted plumbing", + description=( + "Keep hydrocarbon contamination out of everything N2O touches: clean " + "fittings and hoses before assembly, cap open lines between " + "operations, and inspect the fill line and QD before each fill." + ), issue_type=IssueType.TASK, status=IssueStatus.OPEN, priority=IssuePriority.HIGH, - part_id=p["injector"].id, - ) - db.add(task2) - - task3 = Issue( - issue_number=generate_issue_number(db), - title="Write pre-launch checklist procedure", - description="Create formal procedure covering all launch-day activities: range setup, vehicle prep, propellant loading, countdown, and safing.", - issue_type=IssueType.TASK, - status=IssueStatus.OPEN, - priority=IssuePriority.MEDIUM, - ) - db.add(task3) - - # ── Improvements ──────────────────────────────────────────── - - imp1 = Issue( - issue_number=generate_issue_number(db), - title="Add redundant barometric altimeter for recovery", - description="Single MS5611 is a single point of failure for apogee detection and recovery deployment.", - issue_type=IssueType.IMPROVEMENT, - status=IssueStatus.OPEN, - priority=IssuePriority.MEDIUM, - part_id=p["altimeter"].id, - expected_benefit="Dual-sensor voting eliminates single-point failure for apogee detection. If primary altimeter fails, backup independently triggers recovery events. Required to meet REQ-004 (dual-event recovery with redundant altimeters).", - ) - db.add(imp1) - - imp2 = Issue( - issue_number=generate_issue_number(db), - title="Replace e-match igniters with electronic igniters", - description="E-matches require pyrotechnic handling during pad operations. Electronic (resistive) igniters would simplify pad procedures.", - issue_type=IssueType.IMPROVEMENT, - status=IssueStatus.OPEN, - priority=IssuePriority.LOW, - expected_benefit="Eliminates pyrotechnic handling during pad operations. Enables remote re-arm capability without approaching vehicle. Reduces hazmat paperwork for launch site operations.", - ) - db.add(imp2) - - db.flush() - - # ── Issue Comments ────────────────────────────────────────── - - db.add( - IssueComment( - issue_id=nc1.id, - body="Measured all 32 holes with pin gauges. Hole diameters are within spec — only the bolt circle is off. Likely a fixture alignment issue on the rotary table.", - ) - ) - db.add( - IssueComment( - issue_id=nc1.id, - body="Checked the G-code. Origin offset was set to X0.0025 Y0.0000 instead of X0.0000 Y0.0000. That explains the radial shift. CNC program has been corrected for the remake.", - ) - ) - - db.add( - IssueComment( - issue_id=bug1.id, - body="Scope trace on Vcc rail shows a 1.2V dip lasting ~500 µs coincident with e-match firing. The MOSFET inrush is pulling the rail below the Teensy's brownout threshold (2.7V). Need a bigger bulk cap on the pyro board or separate battery for pyro.", - ) - ) - db.add( - IssueComment( - issue_id=bug1.id, - body="Added 2200 µF low-ESR cap to pyro board Vcc input. Dip reduced to 0.3V — FC stays up now. Will retest with both channels firing simultaneously before closing.", - ) - ) - - db.add( - IssueComment( - issue_id=nc2.id, - body="Sent X-ray images to welding consultant. Recommendation: grind out affected area and re-weld, then re-inspect. Alternatively, could accept with stress analysis showing adequate margin at reduced section.", - ) + containment=Containment.ADVISORY, + assigned_to_id=build.id, + raised_by_id=qa.id, + created_at=now - timedelta(days=11), ) + db.add(clean) + issues["n2o_cleanliness"] = clean db.flush() - return { - "injector_nc": nc1, - "weld_nc": nc2, - "oring_groove_nc": nc3, - "regulator_nc": nc4, - "pyro_reset_bug": bug1, - "gps_bug": bug2, - "telemetry_bug": bug3, - "oring_task": task1, - "injector_task": task2, - "checklist_task": task3, - "altimeter_imp": imp1, - "ematch_imp": imp2, - } + return issues # --------------------------------------------------------------------------- -# Risks +# Risks — HCR-5100 §1.4 hazards, with P/I estimates from the extraction # --------------------------------------------------------------------------- def _seed_risks( db: Session, - p: dict[str, Part], + parts: dict[str, Part], users: dict[str, User], issues: dict[str, Issue], ) -> None: - """Four scenario-structured risks, one per common disposition.""" - - # ── open — identified, not yet dispositioned ──────────────── - db.add( - Risk( - risk_number=generate_risk_number(db), - title="Avionics single-battery brownout", - description=( - "Bench testing showed the FC resets when an e-match fires (see the pyro " - "firing bug) — the bus is sensitive to transients. Candidate responses: " - "separate recovery battery, larger bulk capacitance on the pyro board." - ), - condition=( - "the flight computer, telemetry radio, and both pyro channels draw from a " - "single 2S LiPo with no independent backup supply." - ), - departure=( - "the bus voltage sags below the flight computer brownout threshold during " - "pyro firing or sustained transmit in flight" - ), - asset_part_id=p["fc"].id, - consequence=( - "loss of recovery deployment commanding and loss of all flight data from " - "the brownout onward" - ), - disposition=RiskDisposition.OPEN.value, - owner_id=users["test"].id, - probability=2, - impact=5, - ) - ) + from opal.risks.dispositions import accept, set_disposition - # ── mitigate — open mitigation issue + residual target ───── - lox_seal = Risk( - risk_number=generate_risk_number(db), - title="Buna-N seals in LOX-wetted joints", - description=( - "Stock -116 Buna-N O-rings were installed in LOX-wetted seal positions during " - "initial plumbing fit-up. MSFC-SPEC-106B lists Buna-N as incompatible with " - "liquid oxygen. Viton replacements are on order via the linked task; residual " - "assumes all wetted seals are swapped and lot-verified before tanking." - ), - condition=( - "Buna-N -116 O-rings are installed in LOX-wetted seal positions, and Buna-N " - "is not LOX-compatible per MSFC-SPEC-106B." - ), - departure=( - "a seal ignites or embrittles on liquid oxygen contact during tanking or static fire" - ), - asset_part_id=p["engine_assy"].id, - consequence=( - "fire damage to the engine and pad hardware and loss of the static fire campaign" - ), - disposition=RiskDisposition.MITIGATE.value, - owner_id=users["build"].id, - probability=3, - impact=5, - residual_probability=1, - residual_impact=5, - ) - db.add(lox_seal) - db.flush() - db.add( - RiskIssueLink( - risk_id=lox_seal.id, - issue_id=issues["oring_task"].id, - role=RiskIssueRole.MITIGATION.value, - ) - ) + build, test, qa = users["build"], users["test"], users["qa"] + owners = [build, test, qa] - # ── watch — observable + threshold + contingency ──────────── - db.add( - Risk( + for i, entry in enumerate(_data("risks.json")["risks"]): + risk = Risk( risk_number=generate_risk_number(db), - title="Umbilical QD backorder threatens launch window", - description=( - "Swagelok quotes 8-10 weeks on the SS-QC4-B-400 quick-disconnect. Pad " - "fill-system integration is on the critical path; every other fill-system " - "component is in stock." - ), - condition=( - "the umbilical quick-disconnect (SS-QC4-B-400) is on supplier backorder " - "with no confirmed ship date, and pad fill-system integration is on the " - "critical path to the reserved launch window." - ), - departure=( - "the quick-disconnect delivery slips past the start of pad fill-system integration" - ), - asset_text="launch window reservation", - consequence=( - "a launch delay of at least 3 months to the next available site reservation" - ), - disposition=RiskDisposition.WATCH.value, - owner_id=users["build"].id, - probability=4, - impact=3, - watch_observable="Supplier order status for the SS-QC4-B-400 quick-disconnect", - watch_threshold="No ship confirmation by 2026-07-01", - watch_contingency=( - "Fabricate an interim manual-disconnect fill fitting and requalify the " - "fill procedure without auto-shutoff." - ), + title=entry["title"], + description=entry["description"], + condition=entry["condition"], + departure=entry["departure"], + asset_part_id=parts[entry["asset_part"]].id if entry.get("asset_part") else None, + asset_text=entry.get("asset_text"), + consequence=entry["consequence"], + probability=entry["probability"], + impact=entry["impact"], + owner_id=owners[i % 3].id, ) - ) + db.add(risk) + db.flush() - # ── accepted — signed, with rationale ─────────────────────── - db.add( - Risk( - risk_number=generate_risk_number(db), - title="COTS pressurant bottle accepted on vendor cert", - description=( - "The N2 pressurant bottle is a DOT-rated COTS unit; REQ-002 was verified " - "against the vendor certificate rather than an in-house proof test. The " - "pad pressure check before propellant load detects leak-down." - ), - condition=( - "the pressurant tank is a COTS nitrogen bottle accepted on vendor " - "certification (DOT 3AL3000) without an in-house proof test." - ), - departure="the bottle valve leaks down during the pre-launch pad hold", - asset_part_id=p["press_tank"].id, - consequence="a scrub for that window and the loss of one day of range time", - disposition=RiskDisposition.ACCEPTED.value, - owner_id=users["qa"].id, - probability=2, - impact=2, - accepted_by_id=users["build"].id, - accepted_at=datetime.now(UTC), - acceptance_rationale=( - "Bottle is DOT-rated to 3000 PSI against a 500 PSI working pressure with " - "vendor cert on file (REQ-002 verified). Leak-down is detected by the pad " - "pressure check before propellant load; worst credible outcome is a " - "one-day scrub." - ), - ) - ) + plan = entry.get("disposition") + if plan == "mitigate": + risk.residual_probability, risk.residual_impact = entry["residual"] + db.add( + RiskIssueLink( + risk_id=risk.id, + issue_id=issues[entry["mitigation_issue"]].id, + role=RiskIssueRole.MITIGATION.value, + ) + ) + db.flush() + set_disposition(db, risk, "mitigate", build.id) + elif plan == "watch": + risk.watch_observable = entry["watch_observable"] + risk.watch_threshold = entry["watch_threshold"] + risk.watch_contingency = entry["watch_contingency"] + set_disposition(db, risk, "watch", build.id) + elif plan == "accepted": + risk.owner_id = qa.id + # The signature moment goes through the core accept path so the + # readiness rules hold for the demo row. + accept( + db, + risk, + build.id, + rationale=( + "Exposure is brief skin contact during hose handling; PPE per " + "§7.1 (nitrile gloves, safety glasses) is already a launch-" + "procedure gate and the credible outcome is a minor cold burn. " + "Guidebook mitigation: " + entry["description"] + ), + ) + elif plan == "realized": + risk.realized_issue_id = issues[entry["realized_issue"]].id + db.flush() + set_disposition( + db, + risk, + "realized", + test.id, + note="Shock cord burn-through found at recovery inspection of SN 001.", + ) db.flush() # --------------------------------------------------------------------------- -# Test Templates +# Test templates — the two numeric checks the guidebook states outright # --------------------------------------------------------------------------- -def _seed_test_templates(db: Session, p: dict[str, Part]) -> None: - templates = [ - TestTemplate( - part_id=p["chamber"].id, - name="Hydrostatic Proof Test", - description="Pressurize to 1.5x MEOP (675 PSI) with water. Hold 5 min. No leaks or permanent deformation.", - required=True, - test_type="numeric", - min_value=Decimal("675"), - max_value=Decimal("750"), - unit="PSI", - sort_order=1, - ), - TestTemplate( - part_id=p["lox_tank"].id, - name="Hydrostatic Proof Test", - description="Pressurize to 750 PSI (1.5x 500 PSI MEOP) with water. Hold 5 min.", - required=True, - test_type="numeric", - min_value=Decimal("750"), - max_value=Decimal("800"), - unit="PSI", - sort_order=1, - ), - TestTemplate( - part_id=p["fuel_tank"].id, - name="Hydrostatic Proof Test", - description="Pressurize to 750 PSI (1.5x 500 PSI MEOP) with water. Hold 5 min.", - required=True, - test_type="numeric", - min_value=Decimal("750"), - max_value=Decimal("800"), - unit="PSI", - sort_order=1, - ), - TestTemplate( - part_id=p["engine_assy"].id, - name="Leak Check — Low Pressure", - description="Pressurize assembled engine to 50 PSI with N2. Soap-bubble all joints. No bubbles for 60 seconds.", - required=True, - test_type="boolean", - sort_order=1, - ), - TestTemplate( - part_id=p["lox_valve"].id, - name="Seat Leak Test", - description="Pressurize upstream to 500 PSI with N2, valve closed. Measure downstream leak rate.", - required=True, - test_type="boolean", - sort_order=1, - ), - TestTemplate( - part_id=p["fuel_valve"].id, - name="Seat Leak Test", - description="Pressurize upstream to 500 PSI with N2, valve closed. Measure downstream leak rate.", - required=True, - test_type="boolean", - sort_order=1, - ), - TestTemplate( - part_id=p["ematch"].id, - name="Continuity Check", - description="Measure e-match bridgewire resistance. Must be within 0.5-5.0 Ω for reliable firing.", - required=True, - test_type="numeric", - min_value=Decimal("0.5"), - max_value=Decimal("5.0"), - unit="Ω", - sort_order=1, - ), - TestTemplate( - part_id=p["pyro_board"].id, - name="Isolation Test", - description="Measure resistance between pyro power rail and logic power rail. Must exceed 10 MΩ.", - required=True, - test_type="numeric", - min_value=Decimal("10000000"), - max_value=None, - unit="Ω", - sort_order=1, - ), - ] - db.add_all(templates) +def _seed_test_templates(db: Session, parts: dict[str, Part]) -> None: + db.add_all( + [ + TestTemplate( + part_id=parts["9v_batt"].id, + name="Altimeter Battery Voltage", + description="Measure open-circuit voltage. Replace below 8.2 V " + "(HCR-5100 Table 2.10 spares guidance).", + required=True, + test_type="numeric", + min_value=Decimal("8.2"), + max_value=Decimal("9.9"), + unit="V", + sort_order=1, + ), + TestTemplate( + part_id=parts["tank_36l_047v2x8x313c"].id, + name="Static Vent Clear", + description="Verify the 1.2 mm (0.047 in) static vent orifice is " + "unobstructed (launch procedure 7.1 check).", + required=True, + test_type="boolean", + sort_order=1, + ), + ] + ) db.flush() diff --git a/src/opal/seed_data/sphinx/parts.json b/src/opal/seed_data/sphinx/parts.json new file mode 100644 index 0000000..bb8cb6b --- /dev/null +++ b/src/opal/seed_data/sphinx/parts.json @@ -0,0 +1,4502 @@ +{ + "attribution": "Demo content adapted from HCR-5100 \u2014 Mojave Sphinx Build, Integration, and Launch Guidebook, Half Cat Rocketry, published under the GPL.", + "suppliers": [ + { + "name": "McMaster-Carr", + "website": "https://www.mcmaster.com", + "notes": "Primary COTS source \u2014 fittings, fasteners, seals, raw stock. Appendix F vehicle table: 74 lines." + }, + { + "name": "Amazon", + "website": "https://www.amazon.com", + "notes": "Servos (DS3225), servo extensions, PLA+ filament, tent-tarp chute canopy, Kevlar cord, GSE electronics." + }, + { + "name": "Midwest Steel Supply", + "website": "https://www.midweststeelsupply.com", + "notes": "TCA stock: 110 copper round bar, 6061 round bar and tube." + }, + { + "name": "Chris' Rocket Supplies", + "website": "https://www.csrocketry.com", + "notes": "Recovery: tubular nylon, chute protectors, drogue chute; e-matches and black powder." + }, + { + "name": "Eggtimer Rocketry", + "website": "https://www.eggtimerrocketry.com", + "notes": "Quark dual-deploy altimeter kit (x2). Alternates: PerfectFlite StratologgerCF, Missile Works RRC3+." + }, + { + "name": "SendCutSend", + "website": "https://www.sendcutsend.com", + "notes": "Laser-cut flat parts: TCA flange plates, mounting brackets, recovery/avbay bulkheads." + }, + { + "name": "Retail / Miscellaneous", + "website": null, + "notes": "9V batteries (retail), nitrous oxide (racing shop, 20 lb), E85 (gas station), Estes motors (hobby store), Harbor Freight, Gas Cylinder Source." + } + ], + "parts": [ + { + "key": "vehicle", + "name": "Mojave Sphinx Vehicle", + "description": "Complete N2O/E85 liquid bipropellant rocket, 4 in airframe. HCR-5100 flight vehicle.", + "category": "Airframe", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0001" + }, + { + "key": "tank_assy", + "name": "Propellant Tank Assembly", + "description": "Integral fuel/oxidizer tank: drilled 4 in tube, piston, fuel and oxidizer bulkhead subassemblies, retaining rings.", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0002" + }, + { + "key": "fluid_assy", + "name": "Fluid System", + "description": "Feedlines, fill plumbing, quick disconnect, and fittings between tank outlets and the thrust chamber.", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0003" + }, + { + "key": "valve_assy_grp", + "name": "Valve Actuation Group", + "description": "Fuel and oxidizer servo-actuated ball valves with mounts and hardware.", + "category": "Valves", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0004" + }, + { + "key": "tca_assy", + "name": "Thrust Chamber Assembly", + "description": "Injector, combustion chamber, nozzle with spring-retained copper throat insert, flange plates and tie rods.", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0005" + }, + { + "key": "igniter_assy", + "name": "Igniter Cartridge", + "description": "Pyrotechnic igniter cartridge built from brass pipe fittings.", + "category": "Igniter", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0006" + }, + { + "key": "airframe_assy", + "name": "Airframe Group", + "description": "Nose cone, airframe tubes, fin brackets, fins, rail buttons, templates.", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0007" + }, + { + "key": "recovery_assy", + "name": "Recovery System", + "description": "Main and drogue parachutes, shock cord, protectors, anchors.", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0008" + }, + { + "key": "avbay_assy", + "name": "Avionics Bay Assembly", + "description": "Dual Eggtimer Quark deployment controllers on a plywood sled in a printed coupler with switch band.", + "category": "Avionics", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0009" + }, + { + "key": "sabv_assy", + "name": "Servo-Actuated Ball Valve Assembly", + "description": "SABV-04FF: DS3225 servo on printed mount driving a 1/4 NPT brass ball valve via printed handle connector. Built by the Servo-Actuated Ball Valve Assembly procedure; two per vehicle, one for the GSE fill valve.", + "category": "Valves", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-F-0010" + }, + { + "key": "gse", + "name": "Mojave Sphinx GSE", + "description": "Remotely-controlled fluid handling and firing system in a 21 in steel toolbox: N2O fill/dump, ignition power, vehicle valve command.", + "category": "GSE", + "tier": 2, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-G-0001" + }, + { + "key": "gse_control", + "name": "GSE Control System", + "description": "RC transmitter/receiver, relay, regulator, switches, battery, panel I/O.", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-G-0002" + }, + { + "key": "gse_fill", + "name": "GSE Fill System", + "description": "N2O supply plumbing: supply bottle, fill valve (SABV), gauge, fill line, quick disconnect.", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-G-0003" + }, + { + "key": "gse_enclosure", + "name": "GSE Enclosure", + "description": "21 in steel toolbox with panel holes, eye bolts and battery restraints.", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-G-0004" + }, + { + "key": "gse_integration", + "name": "GSE Integration Hardware", + "description": "Cable stock, purge plumbing, brackets, and hardware from the GSE OP gather lists (not in the 6.2 BOM subtotal).", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "guidebook_pn": null, + "ipn": "SPX-G-0005" + }, + { + "key": "1610t134", + "name": "6061 Aluminum Round 4\"D X 1/2\"L", + "description": "Tank Bulkheads (stock)", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "1610T134", + "price": 14.95, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0014" + }, + { + "key": "1610t33", + "name": "6061 Aluminum Round 4\"D X 3\"L", + "description": "Tank Piston (stock)", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "1610T33", + "price": 22.26, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0015" + }, + { + "key": "9056k423", + "name": "6061 Aluminum Tube 4\"D X 1/8\"W X 36\"L", + "description": "Tank Casing (stock)", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9056K423", + "price": 85.38, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0016" + }, + { + "key": "7392t173", + "name": "6061 Aluminum Tube 4\"OD X 1/2\"W X 3\"L", + "description": "Bolted Tank Rings (stock; McMaster description says 1/4\" wall)", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "7392T173", + "price": 10.37, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0017" + }, + { + "key": "9452k226", + "name": "-238 Buna-N O-Rings (Pack of 50)", + "description": "Tank/Piston Seals", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9452K226", + "price": 16.14, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0015" + }, + { + "key": "91255a378", + "name": "5/16-24 X 1/2\" Button Head Screw (Pack of 25)", + "description": "Tank Bolts \u2014 black-oxide alloy steel", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "91255A378", + "price": 7.57, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0018" + }, + { + "key": "94579a550", + "name": "5/16-24 Offset Narrow Base Weld Nut (Pack of 25)", + "description": "Tank Bolt Nuts \u2014 steel", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "94579A550", + "price": 9.21, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0019" + }, + { + "key": "91306a279", + "name": "1/4-20 X 7/8 Button Head Hex Drive Screws (Pack of 50)", + "description": "Fwd Bulkhead Recovery Anchors, Fin Brackets, Airframe \u2014 zinc-plated alloy steel", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "91306A279", + "price": 9.3, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0020" + }, + { + "key": "90264a435", + "name": "1/4-20 Coupling Nut", + "description": "Fwd Bulkhead Recovery Anchors \u2014 zinc-plated steel", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "90264A435", + "price": 0.39, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0021" + }, + { + "key": "91367a952", + "name": "Sealing Washers for 1/4\" Screw, 0.5\" OD (Pack of 10)", + "description": "Fwd Bulkhead Recovery Anchors \u2014 fluorosilicone", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "91367A952", + "price": 7.49, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0022" + }, + { + "key": "90357a013", + "name": "Ultra-Low-Profile Socket Head Screw, 1/4\"-20, 1/2\" Long", + "description": "Propellant Piston Extraction \u2014 alloy steel", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "90357A013", + "price": 4.93, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0023" + }, + { + "key": "93320a215", + "name": "Spacer, for 5/16\", 5/8\" OD x 1/2\" Long", + "description": "Bushing for Tank Drill Jig", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "93320A215", + "price": 4.31, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0024" + }, + { + "key": "tank_36l_047v2x8x313c", + "name": "Propellant Tank Casing", + "description": "Machined \u2014 6061 aluminum (made from 9056K423)", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "TANK-36L-047V2X8X313C", + "ipn": "SPX-F-0023" + }, + { + "key": "bkhd_fl_o238_4npt2x250c", + "name": "Fuel Tank Bulkhead", + "description": "Machined \u2014 6061 aluminum (made from 1610T134)", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "BKHD-FL-O238-4NPT2X250C", + "ipn": "SPX-F-0024" + }, + { + "key": "bkhd_ox_o238_8npt4npt", + "name": "Oxidizer Tank Bulkhead", + "description": "Machined \u2014 6061 aluminum (made from 1610T134)", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "BKHD-OX-O238-8NPT4NPT", + "ipn": "SPX-F-0025" + }, + { + "key": "pstn_2xo238_250t20", + "name": "Propellant Piston", + "description": "Machined \u2014 6061 aluminum (made from 1610T33)", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "PSTN-2XO238-250T20", + "ipn": "SPX-F-0026" + }, + { + "key": "inrg_200s_8x3125c8x250c", + "name": "Interface Ring", + "description": "Machined \u2014 6061 aluminum (made from 7392T173)", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "INRG-200S-8X3125C8X250C", + "ipn": "SPX-F-0027" + }, + { + "key": "ntrg_45c20d_200e", + "name": "Nut Retaining Ring", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "NTRG-45C20D-200E", + "ipn": "SPX-F-0028" + }, + { + "key": "drljg_tank_8x625od_bshng", + "name": "Propellant Tank Drill Jig", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": true, + "guidebook_pn": "DRLJG-TANK-8X625OD-BSHNG", + "ipn": "SPX-D-0001" + }, + { + "key": "44665k137", + "name": "Aluminum Pipe Nipple, 1/4NPT, 4\"L", + "description": "Tank outlets to valves", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "44665K137", + "price": 5.09, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0029" + }, + { + "key": "50785k43", + "name": "Brass High Pressure Elbow Fitting, M/F, 1/4NPT", + "description": "Fuel valve outlet", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K43", + "price": 2.94, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0030" + }, + { + "key": "5485k31", + "name": "High-Pressure Brass Straight Reducer, 1/4 x 1/8 NPT Male", + "description": "Fuel valve outlet", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "5485K31", + "price": 2.64, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0031" + }, + { + "key": "50785k35", + "name": "High-Pressure Brass Elbow Connector, 1/8 NPT Female", + "description": "Fuel valve outlet", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K35", + "price": 3.31, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0032" + }, + { + "key": "50675k435", + "name": "37 Degree Flared Adapter for 5/16\" Tube OD, 1/8 Pipe Size", + "description": "Fuel valve outlet (additional qty 1 on TCA BOM)", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50675K435", + "price": 5.38, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0033" + }, + { + "key": "4468k858", + "name": "High-Pressure Braided Chemical Hose w/ Fittings, Brass 1/2\"-20 Flare UNF Female, 3/8\" OD, 42\"", + "description": "Fuel Line 42\" Section", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "4468K858", + "price": 30.36, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0034" + }, + { + "key": "4468k031", + "name": "High-Pressure Braided Chemical Hose w/ Fittings, Brass 1/2\"-20 Flare UNF Female, 3/8\" OD, 14\"", + "description": "Fuel Line 14\" Section", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "4468K031", + "price": 19.32, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0035" + }, + { + "key": "4468k865", + "name": "High-Pressure Braided Chemical Hose w/ Fittings, Brass 9/16\"-18 Flare UNF Female, 7/16\" OD, 12\"", + "description": "Ox Line", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "4468K865", + "price": 27.61, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0036" + }, + { + "key": "50675k135", + "name": "37 Degree Flared Straight Connector for 5/16\" Tube OD, 1-3/8\" Long", + "description": "Fuel Line Union", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50675K135", + "price": 4.47, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0037" + }, + { + "key": "50675k163", + "name": "37 Degree Flared Adapter for 3/8\" Tube OD x 1/4 NPT Male", + "description": "Ox Valve Outlet", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50675K163", + "price": 3.65, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0038" + }, + { + "key": "9685t3", + "name": "Nylon Tubing, 1/4\" OD, 800PSI, 10ft", + "description": "Fill tube", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9685T3", + "price": 11.5, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0039" + }, + { + "key": "7768k21", + "name": "Check Valve, 1/8NPT Female/Male, Brass", + "description": "Fill", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "7768K21", + "price": 15.0, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0040" + }, + { + "key": "50785k41", + "name": "Elbow, 1/8NPT, Male/Female, Brass", + "description": "Fill check to QD", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K41", + "price": 2.16, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0041" + }, + { + "key": "9396t31", + "name": "Push to Connect Fitting, 1/4\" Tube X 1/8NPT", + "description": "Fill check to QD", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9396T31", + "price": 3.95, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0042" + }, + { + "key": "8974k299", + "name": "3/4\" X 6\" 6061-T6 Round Bar", + "description": "QD Stock Material \u2014 6061-T6 aluminum", + "category": "Fluid System", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "8974K299", + "price": 4.39, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0025" + }, + { + "key": "9452k15", + "name": "-007 Buna-N O-rings (Pack of 100)", + "description": "QD Seal", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9452K15", + "price": 4.25, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0044" + }, + { + "key": "50785k27", + "name": "High Pressure Brass Straight Adapter, 1/4NPT Female x Male", + "description": "Fuel valve outlet spacer", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K27", + "price": 2.66, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0045" + }, + { + "key": "qdc_750x125flg_281fb8npt", + "name": "Quick Disconnect, 3/4\" x 1/8\" Flange, 9/32\" Female Bore, 1/8 NPT", + "description": "Machined \u2014 6061-T6 aluminum (made from 8974K299)", + "category": "Fluid System", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "QDC-750X125FLG-281FB8NPT", + "ipn": "SPX-F-0046" + }, + { + "key": "qdc_750x125flg_270mb_o007", + "name": "Quick Disconnect, 3/4\" x 1/8\" Flange, .270 Male Barb, -007 O-Ring Gland", + "description": "Machined \u2014 6061-T6 aluminum (made from 8974K299)", + "category": "Fluid System", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "QDC-750X125FLG-270MB-O007", + "ipn": "SPX-F-0047" + }, + { + "key": "qdc_750x250clp", + "name": "Quick Disconnect Clip, for 3/4\" x 1/8\" Flange", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Fluid System", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "QDC-750X250CLP", + "ipn": "SPX-F-0048" + }, + { + "key": "ds3225", + "name": "25kg Servo w/ 25T Servo Horn (Pack of 2)", + "description": "Fuel/Ox valve actuators; 25KG high torque waterproof RC servo, full metal gear, 270 deg", + "category": "Valves", + "tier": 1, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "DS3225", + "price": 28.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0049" + }, + { + "key": "4112t22", + "name": "Compact High Pressure Ball Valve, 1/4NPT", + "description": "Fuel/Ox Valves \u2014 brass", + "category": "Valves", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "4112T22", + "price": 17.26, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0050" + }, + { + "key": "90480a009", + "name": "8-32 Hex Nut, Low Strength (Pack of 100)", + "description": "Valve Handle Screw \u2014 zinc-plated steel", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "90480A009", + "price": 2.02, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0026" + }, + { + "key": "90591a141", + "name": "M4X0.7 Hex Nut, Low Strength (Pack of 100)", + "description": "Servo mount screws \u2014 zinc-plated steel", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "90591A141", + "price": 1.9, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0027" + }, + { + "key": "90272a194", + "name": "8-32 X 1/2\"L Screw, Zinc Plated Steel (Pack of 100)", + "description": "Valve Handle Screw; missing from Appendix F vehicle McMaster table (appears only in GSE table) \u2014 zinc-plated steel", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "90272A194", + "price": 3.98, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0028" + }, + { + "key": "91280a044", + "name": "M4X0.7 X 16mm Hex Head Screws, 18-8SS (Pack of 100)", + "description": "Servo mount screws, Avbay Sled \u2014 class 8.8 steel per McMaster description", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "91280A044", + "price": 10.56, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0029" + }, + { + "key": "98689a113", + "name": "M4 Washer, 8mm OD, 18-8SS (Pack of 100)", + "description": "Servo mount screws \u2014 18-8 stainless steel", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "98689A113", + "price": 3.43, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0030" + }, + { + "key": "92148a160", + "name": "M4 Split Lock Washer, 18-8SS (Pack of 100)", + "description": "Servo mount screws \u2014 18-8 stainless steel", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "92148A160", + "price": 1.94, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0031" + }, + { + "key": "93475a210", + "name": "M3 Flat Washer, 18-8SS, 7mm OD (Pack of 100)", + "description": "Servo Horn Connector \u2014 18-8 stainless steel", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "93475A210", + "price": 2.19, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0032" + }, + { + "key": "92855a310", + "name": "M3x0.5 10mm Low Profile Socket Head Screw, 18-8SS (Pack of 25)", + "description": "Servo Horn Connector \u2014 18-8 stainless steel", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "92855A310", + "price": 2.28, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0033" + }, + { + "key": "srv_3pin_1mxt", + "name": "3-pin Servo Extension Cables, 1m (Pack of 10)", + "description": "Servo wires", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "SRV-3PIN-1MXT", + "price": 9.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0034" + }, + { + "key": "srv_y_hrns", + "name": "3-Pin Servo Y-Harness", + "description": "Optional; can make by splicing extensions", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "SRV-Y-HRNS", + "price": 7.99, + "lifecycle": "draft", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0035" + }, + { + "key": "pla_fil_2kg", + "name": "PLA+ 3D Printer Filament, White, 2kg (Or Equivalent)", + "description": "Valve actuator housings (", + "category": "Valves", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "PLA-FIL-2KG", + "price": 45.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0036" + }, + { + "key": "sabv_mount_v2", + "name": "Servo Valve Mount", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Valves", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "SABV-MOUNT-V2", + "ipn": "SPX-F-0062" + }, + { + "key": "sabv_hcon_v2", + "name": "Servo Valve Handle Connector", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Valves", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "SABV-HCON-V2", + "ipn": "SPX-F-0063" + }, + { + "key": "61arb3", + "name": "6061 Aluminum Round 3\"D X 6\"L", + "description": "Nozzle and injector stock; alt 1a: McMaster 1610T41", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Midwest Steel Supply", + "vendor_pn": "61ARB3", + "price": 23.23, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0037" + }, + { + "key": "61ardt3500", + "name": "6061 Aluminum Tube 3\"D X 1/2\"W X 6\"L", + "description": "Chamber Tube stock; alt 2a: McMaster 1610T42 (3-1/2\" dia)", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Midwest Steel Supply", + "vendor_pn": "61ARDT3500", + "price": 37.0, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0038" + }, + { + "key": "crb112", + "name": "110 Copper Round Bar 1-1/2\" Diameter 2\" Long", + "description": "Nozzle Throat Insert stock; alt 3a: McMaster 9103K93", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Midwest Steel Supply", + "vendor_pn": "CRB112", + "price": 18.88, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0039" + }, + { + "key": "50675k164", + "name": "37 Degree Flared Adapter for 3/8\" Tube OD x 3/8 NPT Male", + "description": "Ox Inlet", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50675K164", + "price": 5.18, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0067" + }, + { + "key": "9396k79", + "name": "-141 Silicone O-rings (Pack of 10)", + "description": "Injector Seals \u2014 high-temperature silicone", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9396K79", + "price": 10.17, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0068" + }, + { + "key": "90213a101", + "name": "Inverted External Retaining Ring for 2.5\" OD", + "description": "Injector Flange \u2014 black-phosphate 1060-1090 spring steel", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "90213A101", + "price": 1.18, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0069" + }, + { + "key": "94095k114", + "name": "Graphite Pipe Gasket, Size 1", + "description": "Chamber/nozzle seal", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "94095K114", + "price": 2.83, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0070" + }, + { + "key": "flng_st_25t_8x250c", + "name": "Flange Plate, Thrust Chamber, Steel, 1/4\"", + "description": "Chamber flanges; laser cut (outsourced to SendCutSend)", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": 9.28, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "FLNG-ST-25T-8X250C", + "ipn": "SPX-F-0071" + }, + { + "key": "brkt_90a_125t_2x250c", + "name": "Bracket, Thrust Chamber Mounting, Aluminum, 1/8\"", + "description": "Chamber attachment to fin brackets; laser cut (outsourced to SendCutSend)", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": 5.18, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "BRKT-90A-125T-2X250C", + "ipn": "SPX-F-0072" + }, + { + "key": "92141a029", + "name": "1/4 Washers 5/8\" OD 18-8SS (Pack of 100)", + "description": "Chamber tie rods, fins, avbay \u2014 18-8 stainless steel", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "92141A029", + "price": 5.5, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0040" + }, + { + "key": "91102a750", + "name": "Split Lock Washer for 1/4 Screw Size (Pack of 100)", + "description": "Chamber tie rods, fins, avbay \u2014 zinc-plated steel", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "91102A750", + "price": 3.02, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0041" + }, + { + "key": "95505a601", + "name": "1/4-20 Medium Strength Hex Nuts (Pack of 100)", + "description": "Chamber tie rods, fins, avbay \u2014 grade 5 steel", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "95505A601", + "price": 7.08, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0042" + }, + { + "key": "90281a102", + "name": "1/4-20 x 8\"L Black Oxide Threaded on Both Ends Stud", + "description": "Chamber tie rods (", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "90281A102", + "price": 2.59, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0043" + }, + { + "key": "90015a410", + "name": "Low-Profile Ultra-Wd Truss Head Slotted Screw 1/4\"-20 Thread, 3/4\" Long", + "description": "Scrintle Screw", + "category": "Propulsion", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "90015A410", + "price": 8.32, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0044" + }, + { + "key": "9368t14", + "name": "High-Temperature Dry-Running 841 Bronze Sleeve Bearing, 1/4\" Shaft, 3/8\" Housing ID, 3/8\" Long", + "description": "Scrintle Sleeve", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9368T14", + "price": 0.97, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0078" + }, + { + "key": "8284n57", + "name": "Garter Spring, Hard Drawn Steel, 1.529\" OD, 1.341\" ID", + "description": "Nozzle Insert Retainer", + "category": "Propulsion", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "8284N57", + "price": 5.31, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0079" + }, + { + "key": "injc_2x8npt_38npt_250t20_2xo230_basic", + "name": "Injector, Basic Orifice Pattern", + "description": "Machined \u2014 6061 aluminum (made from 61ARB3)", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "INJC-2X8NPT-38NPT-250T20-2XO230-BASIC", + "ipn": "SPX-F-0080" + }, + { + "key": "nzzl_45c20d_200e", + "name": "Nozzle, for Throat Insert", + "description": "Machined \u2014 6061 aluminum (made from 61ARB3)", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "NZZL-45C20D-200E", + "ipn": "SPX-F-0081" + }, + { + "key": "cmbr_200di_250b_525l", + "name": "Combustion Chamber", + "description": "Machined \u2014 6061 aluminum (made from 61ARDT3500)", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "CMBR-200DI-250B-525L", + "ipn": "SPX-F-0082" + }, + { + "key": "thrt_45c20d_100t", + "name": "Throat Insert, Spring Retained", + "description": "Machined \u2014 110 copper (made from CRB112)", + "category": "Propulsion", + "tier": 1, + "tracking": "serialized", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "THRT-45C20D-100T", + "ipn": "SPX-F-0083" + }, + { + "key": "2155k18", + "name": "Thick-Wall Pipe Nipple, 1/2NPT X 2\"L, Aluminum", + "description": "Cartridge body", + "category": "Igniter", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "2155K18", + "price": 8.28, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0084" + }, + { + "key": "50785k164", + "name": "1/2NPT Cap, Brass", + "description": "Cartridge cap", + "category": "Igniter", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K164", + "price": 2.96, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0085" + }, + { + "key": "50785k94", + "name": "1/2NPT Coupling, Brass", + "description": "Cartridge body", + "category": "Igniter", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K94", + "price": 4.05, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0086" + }, + { + "key": "4429k421", + "name": "NPT Bushing, 1/2 Male X 1/8 Female, Brass", + "description": "Cartridge bottom", + "category": "Igniter", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "4429K421", + "price": 4.72, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0087" + }, + { + "key": "50785k171", + "name": "Pipe Nipple, 1/8NPT X 2\"L, Brass", + "description": "Cartridge outlet", + "category": "Igniter", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K171", + "price": 2.78, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0088" + }, + { + "key": "50785k25", + "name": "High-Pressure Brass Pipe Fitting Straight Adapter with Hex Body, 1/8 NPT Female x Male", + "description": "Cartridge outlet", + "category": "Igniter", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K25", + "price": 1.4, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0089" + }, + { + "key": "5485k21", + "name": "High-Pressure Brass Pipe Straight Connector, 1/8 NPT Male", + "description": "Cartridge outlet", + "category": "Igniter", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "5485K21", + "price": 1.6, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0090" + }, + { + "key": "50785k91", + "name": "High-Pressure Brass Straight Connector with Hex Body, 1/8 NPT Female", + "description": "Igniter inlet", + "category": "Igniter", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K91", + "price": 1.46, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0091" + }, + { + "key": "nscn_sldr_4x250r8x832hnc", + "name": "Nose Cone Shoulder, 4X 1/4\" Rod Sleeves, for 8X 8-32 Hex Nuts with Clearance Holes", + "description": "3D printed \u2014 PLA+ printer filament (on Valves BOM)", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "NSCN-SLDR-4X250R8X832HNC", + "ipn": "SPX-F-0092" + }, + { + "key": "nscn_sc1_4x250r", + "name": "Nose Cone, Section 1, 4X 1/4\" Rod Sleeves", + "description": "3D printed \u2014 PLA+ printer filament (on Valves BOM)", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "NSCN-SC1-4X250R", + "ipn": "SPX-F-0093" + }, + { + "key": "nscn_sc2_4x250r", + "name": "Nose Cone, Section 2, 4X 1/4\" Rod Sleeves", + "description": "3D printed \u2014 PLA+ printer filament (on Valves BOM)", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "NSCN-SC2-4X250R", + "ipn": "SPX-F-0094" + }, + { + "key": "nscn_tip_4x250r250hnc", + "name": "Nose Cone, Tip, 4X 1/4\" Rod Sleeves, 1/4\" Clearance Hole with Hex Nut Capture", + "description": "3D printed \u2014 PLA+ printer filament (on Valves BOM)", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "NSCN-TIP-4X250R250HNC", + "ipn": "SPX-F-0095" + }, + { + "key": "98831a028", + "name": "1/4-20 Nylon Threaded Rod, 24\"L", + "description": "Nose Cone Tension Rods; Appendix F lists qty 8 \u2014 nylon (white)", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "98831A028", + "price": 6.35, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0045" + }, + { + "key": "95229a420", + "name": "Cadmium-Plated Steel MIL. Spec. Washer for 1/4\" Screw Size, NAS 1149-F0432P", + "description": "Nose Cone Tension Rods", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "95229A420", + "price": 5.21, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0046" + }, + { + "key": "3018t23", + "name": "Galvanized Steel Eyebolt with Nut, with Shoulder, for Lifting, 1/4\"-20, 1-1/2\" Thread Length, 3\" Shank", + "description": "Nose Cone Recovery Anchor", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "3018T23", + "price": 5.54, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0098" + }, + { + "key": "92994a029", + "name": "316 Stainless Steel Cap Nut, 1/4\"-20 Thread Size", + "description": "Nose Cone Eyebolt Nut", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "92994A029", + "price": 2.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0047" + }, + { + "key": "20545t38", + "name": "Airframe - 4\" Mailing Tube, 36\"L", + "description": "Round shipping tube with press-on end caps, 4\" ID, 36\" inside length \u2014 .08\" wall cardboard, white", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "20545T38", + "price": 8.6, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0100" + }, + { + "key": "avby_hf_3994", + "name": "Avionics Bay Coupler, Half, 3.994\" OD, Printed", + "description": "3D printed \u2014 PLA+ printer filament (on Valves BOM)", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "AVBY-HF-3994", + "ipn": "SPX-F-0101" + }, + { + "key": "avby_sb_4160", + "name": "Avionics Bay Switch Band, 4.16\" OD, Printed", + "description": "3D printed \u2014 PLA+ printer filament (on Valves BOM)", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "AVBY-SB-4160", + "ipn": "SPX-F-0102" + }, + { + "key": "3043t643", + "name": "U-Bolt with Mounting Plate, Galvanized, 1/4\"-20 Thread Size, 1\" ID", + "description": "Shock Cord Anchor Point \u2014 zinc-plated steel", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "3043T643", + "price": 1.31, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0103" + }, + { + "key": "92580a328", + "name": "1/4-20 X 10\"L Threaded Rod, B7 Medium Strength", + "description": "Avbay rods; Appendix F lists qty 1 \u2014 grade B7 steel", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "92580A328", + "price": 3.6, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0048" + }, + { + "key": "8982k402", + "name": "Aluminum Angle, 1\" X 1\" X 1/8\" X 24\"L", + "description": "Fin brackets \u2014 6061 aluminum", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "8982K402", + "price": 7.59, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0049" + }, + { + "key": "spcr_400sd_250c0313c", + "name": "Spacer, Fin Bracket to Tank", + "description": "3D printed \u2014 PLA+ printer filament (on Valves BOM)", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "SPCR-400SD-250C0313C", + "ipn": "SPX-F-0106" + }, + { + "key": "1125t412", + "name": "1/4\" x 12\" x 24\" Birch Plywood", + "description": "Fins (stock) \u2014 marine-grade plywood", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "1125T412", + "price": 18.48, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0050" + }, + { + "key": "91255a839", + "name": "5/16-24 X 7/8\" Button Head Screws (Pack of 10)", + "description": "Fin Bracket Attachment to Tank \u2014 black-oxide alloy steel", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "91255A839", + "price": 4.17, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0051" + }, + { + "key": "7631a82", + "name": "Aluminum Foil Tape, Acrylic Adhesive, 2\" Wide, 15 Feet Length, 0.005\" Thick", + "description": "Coupler Shimming", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "7631A82", + "price": 7.79, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0052" + }, + { + "key": "97654a620", + "name": "18-8 Stainless Steel Flanged Button Head Screw, 1/4\"-20 Thread, 1-1/4\" Long (Pack of 5)", + "description": "Rail Button Screws", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "97654A620", + "price": 6.44, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0053" + }, + { + "key": "rlbtn_a_1515_250c", + "name": "Rail Button, 1515, Printed, Part A", + "description": "3D printed \u2014 PLA+ printer filament (on Valves BOM)", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "RLBTN-A-1515-250C", + "ipn": "SPX-F-0111" + }, + { + "key": "rlbtn_b_1515_250c", + "name": "Rail Button, 1515, Printed, Part B", + "description": "3D printed \u2014 PLA+ printer filament (on Valves BOM)", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "RLBTN-B-1515-250C", + "ipn": "SPX-F-0112" + }, + { + "key": "tmplt_af_lwr_416", + "name": "Lower Airframe Template", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": true, + "guidebook_pn": "TMPLT-AF-LWR-416", + "ipn": "SPX-D-0002" + }, + { + "key": "tmplt_af_upr_416", + "name": "Upper Airframe Template", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": true, + "guidebook_pn": "TMPLT-AF-UPR-416", + "ipn": "SPX-D-0003" + }, + { + "key": "plg_dril_af_upr_416", + "name": "Upper Airframe Backing Plug", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": true, + "guidebook_pn": "PLG-DRIL-AF-UPR-416", + "ipn": "SPX-D-0004" + }, + { + "key": "tmplt_fin_brkt_1x1_aft", + "name": "Fin Bracket Template, Aft", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": true, + "guidebook_pn": "TMPLT-FIN-BRKT-1X1-AFT", + "ipn": "SPX-D-0005" + }, + { + "key": "tmplt_fin_brkt_1x1_fwd", + "name": "Fin Bracket Template, Forward", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Airframe", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": true, + "guidebook_pn": "TMPLT-FIN-BRKT-1X1-FWD", + "ipn": "SPX-D-0006" + }, + { + "key": "egtmr_qrk", + "name": "Eggtimer Quark Dual Deploy Altimeter Kit", + "description": "Deployment controller; alt 1a: PerfectFlite StratologgerCF (SLCF), alt 1b: Missile Works RRC3+", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Eggtimer Rocketry", + "vendor_pn": "EGTMR-QRK", + "price": 20.0, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0113" + }, + { + "key": "cnpy_cht_86in", + "name": "7.2ft Hexagonal Tent Tarp, Main Chute Canopy", + "description": "Alternate main chute canopy (saves $55.94); Hikeman hexagonal tent footprint (Medium)", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "CNPY-CHT-86IN", + "price": 24.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0114" + }, + { + "key": "crd_kvlr_250lb", + "name": "250lb Kevlar Cord, 100ft", + "description": "Main chute canopy shroud lines; emma kites 100% Kevlar braided string", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "CRD-KVLR-250LB", + "price": 13.95, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0115" + }, + { + "key": "rec_cht_drg_36", + "name": "Drogue Parachute, 36\" Diameter", + "description": "Drogue chute; alt 4a: Top Flight Recovery PAR-36", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Chris' Rocket Supplies", + "vendor_pn": "REC-CHT-DRG-36", + "price": 20.0, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0116" + }, + { + "key": "rec_tnsc_3klb", + "name": "Tubular Nylon Shock Cord, 3000lbf Breaking Strength", + "description": "Shock cord; alt 5b: Strapworks WSR-NYL-BWT-058-HOG (BlueWater 5/8\" hot orange, 50 ft)", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ft", + "procurement": "buy", + "supplier": "Chris' Rocket Supplies", + "vendor_pn": "REC-TNSC-3KLB", + "price": 0.55, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0117" + }, + { + "key": "rec_prt_16", + "name": "Parachute Protector, Nomex, 16 X 16\"", + "description": "Main and Drogue Chute Protector; alt 6a: Top Flight FCP-18x18\" FIREWALL", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Chris' Rocket Supplies", + "vendor_pn": "REC-PRT-16", + "price": 9.5, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0118" + }, + { + "key": "avby_sld_225x600_6xm4c", + "name": "Avionics Bay Sled, Plywood, 2.25\" X 6.00\", 6X M4 Clearance Holes", + "description": "Avbay sled \u2014 printer filament + plywood", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "AVBY-SLD-225X600-6XM4C", + "ipn": "SPX-F-0119" + }, + { + "key": "bkhd_rec_399_250c_2x170we", + "name": "Bulkhead, Recovery/Avionics Bay, 3.99\" OD, 4X 1/4\" Clearance Holes", + "description": "Avbay bulkheads, recovery bulkhead; laser cut (outsourced to SendCutSend)", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": 6.78, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "BKHD-REC-399-250C-2X170WE", + "ipn": "SPX-F-0120" + }, + { + "key": "avby_cr_4r", + "name": "Avionics Bay Bulkhead Centering Ring, for 4-Rod Coupler", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "AVBY-CR-4R", + "ipn": "SPX-F-0121" + }, + { + "key": "avby_mt_250r_m4c", + "name": "Avionics Bay Sled Mount, for .25\" Rods, M4 Clearance Holes", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "AVBY-MT-250R-M4C", + "ipn": "SPX-F-0122" + }, + { + "key": "avby_bh_2x9v250r_m4c", + "name": "Avionics Bay Battery Holder, 2X 9V Batteries, for .25\" Rods, M4 Clearance Holes", + "description": "3D printed \u2014 PLA+ printer filament", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "AVBY-BH-2X9V250R-M4C", + "ipn": "SPX-F-0123" + }, + { + "key": "93505a211", + "name": "Hex Standoffs, 2-56, Aluminum", + "description": "Altimeter mounting; Appendix F lists qty 1", + "category": "Recovery", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "93505A211", + "price": 0.52, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0054" + }, + { + "key": "91755a152_91755a200", + "name": "Nylon Washers, #2 Screw Size", + "description": "Altimeter Insulation (McMaster description: self-retaining washer)", + "category": "Recovery", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "91755A152-91755A200", + "price": 3.97, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0055" + }, + { + "key": "93135a013", + "name": "Nylon Pan Head Screws, Phillips, 2-56 Thread, 1/4\" Long", + "description": "Altimeter mounting, Shear Pins, Nose Cone; PN/link discrepancy in source (Appendix F uses 93135A013)", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "93135A013", + "price": 8.43, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0126" + }, + { + "key": "7712k511", + "name": "9V Battery Connector", + "description": "Altimeter Power; battery holder, T-shape single layout", + "category": "Recovery", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "7712K511", + "price": 1.21, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0056" + }, + { + "key": "9v_batt", + "name": "9V Alkaline Batteries (Pack of 2)", + "description": "Altimeter Power; any retail store", + "category": "Recovery", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "Retail / Miscellaneous", + "vendor_pn": "9V-BATT", + "price": 8.0, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0057" + }, + { + "key": "8947t26", + "name": "Quick Link 1400lb Capacity", + "description": "Shock cord attachment; oval threaded connecting link, not for lifting \u2014 316 stainless steel", + "category": "Recovery", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "8947T26", + "price": 2.97, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0129" + }, + { + "key": "20ga_rbp", + "name": "20GA Silicone Coated Wire, Red & Black, 10ft Each", + "description": "Avbay wiring \u2014 silicone-insulated tinned copper", + "category": "Recovery", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "20GA-RBP", + "price": 8.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0058" + }, + { + "key": "nitrous_oxide", + "name": "Nitrous Oxide", + "description": "Oxidizer; $5.50/lb", + "category": "Consumables", + "tier": 3, + "tracking": "bulk", + "uom": "lb", + "procurement": "buy", + "supplier": "Retail / Miscellaneous", + "vendor_pn": null, + "price": 5.5, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0007" + }, + { + "key": "e85", + "name": "E85", + "description": "Fuel; $3.09/gal", + "category": "Consumables", + "tier": 3, + "tracking": "bulk", + "uom": "gal", + "procurement": "buy", + "supplier": "Retail / Miscellaneous", + "vendor_pn": null, + "price": 3.09, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0008" + }, + { + "key": "a3_4t", + "name": "A3-4T (4-pack)", + "description": "Igniter", + "category": "Consumables", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "Retail / Miscellaneous", + "vendor_pn": "A3-4T", + "price": 10.0, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0009" + }, + { + "key": "1834", + "name": "MJG Firewire E-matches (Pack of 20)", + "description": "Igniter, Ejection Charges; alt 4a: eBay 395157029341", + "category": "Consumables", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "Chris' Rocket Supplies", + "vendor_pn": "1834", + "price": 15.0, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0010" + }, + { + "key": "1318", + "name": "Black Powder, 4F, 1lb", + "description": "Ejection charges; alt 5a: Hodgdon Pyrodex P (FFFG equivalent)", + "category": "Consumables", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Chris' Rocket Supplies", + "vendor_pn": "1318", + "price": 33.0, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0011" + }, + { + "key": "52555t644", + "name": "Nitrile Gloves (Pack of 20)", + "description": "Glove fingers used for ejection charges; or retail store \u2014 nitrile, 4 mil, textured (Large)", + "category": "Consumables", + "tier": 3, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "52555T644", + "price": 7.9, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0012" + }, + { + "key": "7619a11", + "name": "Vinyl Electrical Tape, 3/4\" X 60ft", + "description": "Ejection charges and wire splices; or retail store", + "category": "Consumables", + "tier": 3, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "7619A11", + "price": 1.66, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-D-0013" + }, + { + "key": "dtus_fsi6x_10ch", + "name": "FlySky FS-I6X Transmitter w/ iA10B Receiver", + "description": "Controller; 10CH 2.4GHz RC transmitter with iA10B receiver", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "DTUS_FSI6X_10CH", + "price": 59.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0006" + }, + { + "key": "az18080602_03", + "name": "Toggle Switch Guards (Pack of 6)", + "description": "Switch covers, 12mm mount dia \u2014 plastic", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "pack", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "AZ18080602-03", + "price": 8.49, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0007" + }, + { + "key": "62586", + "name": "12V 160 CCA AGM Battery", + "description": "Electrical power", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Retail / Miscellaneous", + "vendor_pn": "62586", + "price": 49.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0008" + }, + { + "key": "lcatmc1569", + "name": "Relay Module, PWM Controlled, 10A 30V", + "description": "Ignition switch; referenced as PWM-RLY-10A in OP6 gather list but PWM-RLY-30A in OP6 body text", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "LCATMC1569", + "price": 11.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0009" + }, + { + "key": "drok_090600afa", + "name": "12V to 5V Voltage Regulator", + "description": "Battery to receiver; DC 8-35V to 5V 3A buck converter", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "DROK 090600AFA", + "price": 12.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0010" + }, + { + "key": "gardner_bender_gsw_17", + "name": "GSW-17 SPST Toggle Switch with Screw Terminals", + "description": "GSE power switch; ON-OFF, 6A/120VAC", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "Gardner Bender GSW-17", + "price": 5.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0011" + }, + { + "key": "7159k3", + "name": "Straight-Blade Receptacle, 5-15 NEMA, Side Screw Terminals", + "description": "Electrical outlets (IGN/SRV); OP6 refers to it as 7159K91", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "7159K3", + "price": 3.36, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0012" + }, + { + "key": "7526k23_7526k53", + "name": "Nylon Wall Plate for NEMA Outlet", + "description": "Outlet cover; can also 3D print version with labels", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "7526K23/7526K53", + "price": 0.73, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0013" + }, + { + "key": "20lbalvlv_326", + "name": "20lb Nitrous Tank", + "description": "Price includes approx shipping cost; 20 lb aluminum N2O cylinder with handle, CGA 326 \u2014 aluminum", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Retail / Miscellaneous", + "vendor_pn": "20LBALVLV-326", + "price": 197.0, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0014" + }, + { + "key": "79215a673", + "name": "Brass Nipple, Wrench Tighten, CGA 326, 1/4 NPT Male", + "description": "Nitrous tank adapter", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "79215A673", + "price": 8.49, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0015" + }, + { + "key": "79215a672", + "name": "Brass Female CGA 326 Nut for High-Pressure Nipple", + "description": "Nitrous tank adapter", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "79215A672", + "price": 5.9, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0016" + }, + { + "key": "4468k811", + "name": "High-Pressure Braided Chemical Hose, 7/16\"-20 Flare UNF Female, 5/16\" OD, 14 in", + "description": "Tank to valve hose \u2014 braided hose, brass fittings", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "4468K811", + "price": 16.14, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0017" + }, + { + "key": "50675k162", + "name": "37 Degree Flared Adapter, 1/4\" Tube OD x 1/4 NPT Male", + "description": "Tank outlet and valve inlet \u2014 brass", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50675K162", + "price": 3.71, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0018" + }, + { + "key": "50785k92", + "name": "High-Pressure Brass Straight Connector, 1/4 NPT Female", + "description": "Tank adapter to flare fitting", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K92", + "price": 1.78, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0019" + }, + { + "key": "50785k273", + "name": "High-Pressure Brass Through-Wall Straight Connector, 1/4 NPT Female", + "description": "Fill bulkhead fitting", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K273", + "price": 9.63, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0020" + }, + { + "key": "97149a370", + "name": "3/4-16 Hex Nut, 18-8 Black Oxide", + "description": "Bulkhead fitting spacer nut \u2014 18-8 stainless steel, black oxide", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "97149A370", + "price": 1.79, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0021" + }, + { + "key": "50785k222", + "name": "High-Pressure Brass Right-Angle Tee Adapter, 1/4 NPT Female x Male", + "description": "Nitrous gauge tee", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K222", + "price": 5.71, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0022" + }, + { + "key": "5485k22", + "name": "High-Pressure Brass Straight Connector, 1/4 NPT Male", + "description": "Gauge tee to valve", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "5485K22", + "price": 2.67, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0023" + }, + { + "key": "9396t32", + "name": "High-Pressure Push to Connect, 1/4 Tube OD, 1/4 NPT Male", + "description": "Valve to fill line", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9396T32", + "price": 3.84, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0024" + }, + { + "key": "50785k272", + "name": "High-Pressure Brass Through-Wall Straight Connector, 1/8 NPT Female", + "description": "Fill line bulkhead fitting", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "50785K272", + "price": 6.97, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0025" + }, + { + "key": "3846k8", + "name": "Pressure Gauge, Steel Case, 1/4 NPT Male Center Back, 2\" Dial, 0-2000 PSI", + "description": "Supply pressure indication", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "3846K8", + "price": 15.85, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0026" + }, + { + "key": "91111", + "name": "21 Inch Steel Toolbox, Voyager", + "description": "GSE enclosure", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Retail / Miscellaneous", + "vendor_pn": "91111", + "price": 29.99, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0027" + }, + { + "key": "9489t519", + "name": "1/4-20 x 2\" Eye Bolt", + "description": "Battery restraints; routing eyebolt with nut, bent-closed eye, not for lifting \u2014 316 stainless steel", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9489T519", + "price": 2.87, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0028" + }, + { + "key": "emma_kites_ek5580", + "name": "250lb Kevlar Cord, 100 ft", + "description": "QD clip tether / shroud lines for tent tarp chute", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "Amazon", + "vendor_pn": "emma kites EK5580", + "price": 13.95, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0029" + }, + { + "key": "7768k26", + "name": "Brass Threaded Check Valve, 1/4 NPT Male x NPT Female", + "description": "Purge system (optional); prevents CO2/N2O cross-contamination if both valves open simultaneously", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "7768K26", + "price": null, + "lifecycle": "draft", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0030" + }, + { + "key": "9396t61", + "name": "Push-to-Connect Tee, 1/4\" Tube", + "description": "Splices purge line into fill line (only if purge valve included)", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "9396T61", + "price": null, + "lifecycle": "draft", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0031" + }, + { + "key": "16_3_10ftxt", + "name": "10 ft x 16/3 Gauge Indoor/Outdoor Extension Cord", + "description": "Donor cable stock for servo and ignition cables", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": null, + "vendor_pn": "16-3-10FTXT", + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0032" + }, + { + "key": "alg_clp_ins", + "name": "Alligator Electrical Clip, Insulated", + "description": "QTY 2 clips crimped to ignition cable for e-match connection", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "set", + "procurement": "buy", + "supplier": null, + "vendor_pn": "ALG-CLP-INS", + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0033" + }, + { + "key": "brkt_egse_2x8c", + "name": "Electronics Mounting Bracket, GSE, 2X #8 Clearance Holes", + "description": "Mounts receiver and relay module \u2014 printer filament (implied)", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "BRKT-EGSE-2X8C", + "ipn": "SPX-G-0034" + }, + { + "key": "cvr_ant_2x8c", + "name": "Receiver Antenna Cover, GSE, 2X #8 Clearance Holes", + "description": "Protects receiver antennas on outer back face \u2014 printer filament (implied)", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "CVR-ANT-2X8C", + "ipn": "SPX-G-0035" + }, + { + "key": "92295a100", + "name": "Thread-Forming Screws for Plastic, #4, 1/4\" Long", + "description": "Attach toggle switch covers to transmitter \u2014 steel", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": "McMaster-Carr", + "vendor_pn": "92295A100", + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0036" + }, + { + "key": "zip_tie_188w", + "name": "Nylon Zip Tie, 0.188\" width", + "description": "Battery restraint and wire management (2-3 used for battery)", + "category": "GSE", + "tier": 2, + "tracking": "bulk", + "uom": "ea", + "procurement": "buy", + "supplier": null, + "vendor_pn": "ZIP-TIE-188W", + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-G-0037" + }, + { + "key": "fin_std_250t_4x250c", + "name": "Fin, Standard Profile, Plywood", + "description": "1/4 in birch plywood fin, 4X 1/4 in clearance holes; cut from 1125T412 stock per the fin template.", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": "FIN-STD-250T-4X250C", + "ipn": "SPX-F-0131" + }, + { + "key": "fin_bracket_assy", + "name": "Fin Bracket Assembly", + "description": "Drilled aluminum angle with plywood fin and printed spacers \u2014 four identical assemblies built by the Fin Bracket Subassembly procedure.", + "category": "Airframe", + "tier": 1, + "tracking": "bulk", + "uom": "ea", + "procurement": "make", + "supplier": null, + "vendor_pn": null, + "price": null, + "lifecycle": "active", + "is_tooling": false, + "guidebook_pn": null, + "ipn": "SPX-F-0132" + } + ], + "bom": [ + { + "assembly": "tank_assy", + "component": "1610t134", + "qty": 2, + "uom": "ea", + "notes": "Tank Bulkheads (stock)" + }, + { + "assembly": "tank_assy", + "component": "1610t33", + "qty": 1, + "uom": "ea", + "notes": "Tank Piston (stock)" + }, + { + "assembly": "tank_assy", + "component": "9056k423", + "qty": 1, + "uom": "ea", + "notes": "Tank Casing (stock)" + }, + { + "assembly": "tank_assy", + "component": "7392t173", + "qty": 2, + "uom": "ea", + "notes": "Bolted Tank Rings (stock" + }, + { + "assembly": "tank_assy", + "component": "9452k226", + "qty": 1, + "uom": "pack", + "notes": "Tank/Piston Seals" + }, + { + "assembly": "tank_assy", + "component": "91255a378", + "qty": 1, + "uom": "pack", + "notes": "Tank Bolts" + }, + { + "assembly": "tank_assy", + "component": "94579a550", + "qty": 1, + "uom": "pack", + "notes": "Tank Bolt Nuts" + }, + { + "assembly": "tank_assy", + "component": "91306a279", + "qty": 1, + "uom": "pack", + "notes": "Fwd Bulkhead Recovery Anchors, Fin Brackets, Airframe" + }, + { + "assembly": "tank_assy", + "component": "90264a435", + "qty": 2, + "uom": "ea", + "notes": "Fwd Bulkhead Recovery Anchors" + }, + { + "assembly": "tank_assy", + "component": "91367a952", + "qty": 1, + "uom": "pack", + "notes": "Fwd Bulkhead Recovery Anchors" + }, + { + "assembly": "tank_assy", + "component": "90357a013", + "qty": 2, + "uom": "ea", + "notes": "Propellant Piston Extraction" + }, + { + "assembly": "tank_assy", + "component": "93320a215", + "qty": 1, + "uom": "ea", + "notes": "Bushing for Tank Drill Jig" + }, + { + "assembly": "tank_assy", + "component": "tank_36l_047v2x8x313c", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "tank_assy", + "component": "bkhd_fl_o238_4npt2x250c", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "tank_assy", + "component": "bkhd_ox_o238_8npt4npt", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "tank_assy", + "component": "pstn_2xo238_250t20", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "tank_assy", + "component": "inrg_200s_8x3125c8x250c", + "qty": 2, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "tank_assy", + "component": "ntrg_45c20d_200e", + "qty": 2, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "tank_assy", + "component": "drljg_tank_8x625od_bshng", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "fluid_assy", + "component": "44665k137", + "qty": 2, + "uom": "ea", + "notes": "Tank outlets to valves" + }, + { + "assembly": "fluid_assy", + "component": "50785k43", + "qty": 2, + "uom": "ea", + "notes": "Fuel valve outlet" + }, + { + "assembly": "fluid_assy", + "component": "5485k31", + "qty": 1, + "uom": "ea", + "notes": "Fuel valve outlet" + }, + { + "assembly": "fluid_assy", + "component": "50785k35", + "qty": 1, + "uom": "ea", + "notes": "Fuel valve outlet" + }, + { + "assembly": "fluid_assy", + "component": "50675k435", + "qty": 1, + "uom": "ea", + "notes": "Fuel valve outlet (additional qty 1 on TCA BOM)" + }, + { + "assembly": "fluid_assy", + "component": "4468k858", + "qty": 1, + "uom": "ea", + "notes": "Fuel Line 42\" Section" + }, + { + "assembly": "fluid_assy", + "component": "4468k031", + "qty": 1, + "uom": "ea", + "notes": "Fuel Line 14\" Section" + }, + { + "assembly": "fluid_assy", + "component": "4468k865", + "qty": 1, + "uom": "ea", + "notes": "Ox Line" + }, + { + "assembly": "fluid_assy", + "component": "50675k135", + "qty": 1, + "uom": "ea", + "notes": "Fuel Line Union" + }, + { + "assembly": "fluid_assy", + "component": "50675k163", + "qty": 1, + "uom": "ea", + "notes": "Ox Valve Outlet" + }, + { + "assembly": "fluid_assy", + "component": "9685t3", + "qty": 1, + "uom": "ea", + "notes": "Fill tube" + }, + { + "assembly": "fluid_assy", + "component": "7768k21", + "qty": 1, + "uom": "ea", + "notes": "Fill" + }, + { + "assembly": "fluid_assy", + "component": "50785k41", + "qty": 1, + "uom": "ea", + "notes": "Fill check to QD" + }, + { + "assembly": "fluid_assy", + "component": "9396t31", + "qty": 2, + "uom": "ea", + "notes": "Fill check to QD" + }, + { + "assembly": "fluid_assy", + "component": "8974k299", + "qty": 1, + "uom": "ea", + "notes": "QD Stock Material" + }, + { + "assembly": "fluid_assy", + "component": "9452k15", + "qty": 1, + "uom": "pack", + "notes": "QD Seal" + }, + { + "assembly": "fluid_assy", + "component": "50785k27", + "qty": 1, + "uom": "ea", + "notes": "Fuel valve outlet spacer" + }, + { + "assembly": "fluid_assy", + "component": "qdc_750x125flg_281fb8npt", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "fluid_assy", + "component": "qdc_750x125flg_270mb_o007", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "fluid_assy", + "component": "qdc_750x250clp", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "sabv_assy", + "component": "ds3225", + "qty": 1, + "uom": "pack", + "notes": "Fuel/Ox valve actuators" + }, + { + "assembly": "sabv_assy", + "component": "4112t22", + "qty": 2, + "uom": "ea", + "notes": "Fuel/Ox Valves" + }, + { + "assembly": "sabv_assy", + "component": "90480a009", + "qty": 1, + "uom": "pack", + "notes": "Valve Handle Screw" + }, + { + "assembly": "valve_assy_grp", + "component": "90591a141", + "qty": 1, + "uom": "pack", + "notes": "Servo mount screws" + }, + { + "assembly": "sabv_assy", + "component": "90272a194", + "qty": 1, + "uom": "pack", + "notes": "Valve Handle Screw" + }, + { + "assembly": "valve_assy_grp", + "component": "91280a044", + "qty": 1, + "uom": "pack", + "notes": "Servo mount screws, Avbay Sled" + }, + { + "assembly": "valve_assy_grp", + "component": "98689a113", + "qty": 1, + "uom": "pack", + "notes": "Servo mount screws" + }, + { + "assembly": "valve_assy_grp", + "component": "92148a160", + "qty": 1, + "uom": "pack", + "notes": "Servo mount screws" + }, + { + "assembly": "sabv_assy", + "component": "93475a210", + "qty": 1, + "uom": "pack", + "notes": "Servo Horn Connector" + }, + { + "assembly": "sabv_assy", + "component": "92855a310", + "qty": 1, + "uom": "pack", + "notes": "Servo Horn Connector" + }, + { + "assembly": "valve_assy_grp", + "component": "srv_3pin_1mxt", + "qty": 1, + "uom": "pack", + "notes": "Servo wires" + }, + { + "assembly": "valve_assy_grp", + "component": "srv_y_hrns", + "qty": 1, + "uom": "ea", + "notes": "Optional" + }, + { + "assembly": "valve_assy_grp", + "component": "pla_fil_2kg", + "qty": 1, + "uom": "ea", + "notes": "Valve actuator housings" + }, + { + "assembly": "sabv_assy", + "component": "sabv_mount_v2", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "sabv_assy", + "component": "sabv_hcon_v2", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "tca_assy", + "component": "61arb3", + "qty": 1, + "uom": "ea", + "notes": "Nozzle and injector stock" + }, + { + "assembly": "tca_assy", + "component": "61ardt3500", + "qty": 1, + "uom": "ea", + "notes": "Chamber Tube stock" + }, + { + "assembly": "tca_assy", + "component": "crb112", + "qty": 1, + "uom": "ea", + "notes": "Nozzle Throat Insert stock" + }, + { + "assembly": "tca_assy", + "component": "50675k435", + "qty": 1, + "uom": "ea", + "notes": "Fuel Inlet (same PN as Fluid System line 5)" + }, + { + "assembly": "tca_assy", + "component": "50675k164", + "qty": 1, + "uom": "ea", + "notes": "Ox Inlet" + }, + { + "assembly": "tca_assy", + "component": "9396k79", + "qty": 1, + "uom": "pack", + "notes": "Injector Seals" + }, + { + "assembly": "tca_assy", + "component": "90213a101", + "qty": 1, + "uom": "ea", + "notes": "Injector Flange" + }, + { + "assembly": "tca_assy", + "component": "94095k114", + "qty": 2, + "uom": "ea", + "notes": "Chamber/nozzle seal" + }, + { + "assembly": "tca_assy", + "component": "flng_st_25t_8x250c", + "qty": 2, + "uom": "ea", + "notes": "Chamber flanges" + }, + { + "assembly": "tca_assy", + "component": "brkt_90a_125t_2x250c", + "qty": 8, + "uom": "ea", + "notes": "Chamber attachment to fin brackets" + }, + { + "assembly": "tca_assy", + "component": "92141a029", + "qty": 2, + "uom": "pack", + "notes": "Chamber tie rods, fins, avbay" + }, + { + "assembly": "tca_assy", + "component": "91102a750", + "qty": 1, + "uom": "pack", + "notes": "Chamber tie rods, fins, avbay" + }, + { + "assembly": "tca_assy", + "component": "95505a601", + "qty": 1, + "uom": "pack", + "notes": "Chamber tie rods, fins, avbay" + }, + { + "assembly": "tca_assy", + "component": "90281a102", + "qty": 8, + "uom": "ea", + "notes": "Chamber tie rods" + }, + { + "assembly": "tca_assy", + "component": "90015a410", + "qty": 1, + "uom": "ea", + "notes": "Scrintle Screw" + }, + { + "assembly": "tca_assy", + "component": "9368t14", + "qty": 1, + "uom": "ea", + "notes": "Scrintle Sleeve" + }, + { + "assembly": "tca_assy", + "component": "8284n57", + "qty": 1, + "uom": "ea", + "notes": "Nozzle Insert Retainer" + }, + { + "assembly": "tca_assy", + "component": "injc_2x8npt_38npt_250t20_2xo230_basic", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "tca_assy", + "component": "nzzl_45c20d_200e", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "tca_assy", + "component": "cmbr_200di_250b_525l", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "tca_assy", + "component": "thrt_45c20d_100t", + "qty": 1, + "uom": "ea", + "notes": "Machined" + }, + { + "assembly": "igniter_assy", + "component": "2155k18", + "qty": 1, + "uom": "ea", + "notes": "Cartridge body" + }, + { + "assembly": "igniter_assy", + "component": "50785k164", + "qty": 1, + "uom": "ea", + "notes": "Cartridge cap" + }, + { + "assembly": "igniter_assy", + "component": "50785k94", + "qty": 1, + "uom": "ea", + "notes": "Cartridge body" + }, + { + "assembly": "igniter_assy", + "component": "4429k421", + "qty": 1, + "uom": "ea", + "notes": "Cartridge bottom" + }, + { + "assembly": "igniter_assy", + "component": "50785k171", + "qty": 1, + "uom": "ea", + "notes": "Cartridge outlet" + }, + { + "assembly": "igniter_assy", + "component": "50785k25", + "qty": 1, + "uom": "ea", + "notes": "Cartridge outlet" + }, + { + "assembly": "igniter_assy", + "component": "5485k21", + "qty": 1, + "uom": "ea", + "notes": "Cartridge outlet" + }, + { + "assembly": "igniter_assy", + "component": "50785k91", + "qty": 1, + "uom": "ea", + "notes": "Igniter inlet" + }, + { + "assembly": "airframe_assy", + "component": "nscn_sldr_4x250r8x832hnc", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "nscn_sc1_4x250r", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "nscn_sc2_4x250r", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "nscn_tip_4x250r250hnc", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "98831a028", + "qty": 2, + "uom": "ea", + "notes": "Nose Cone Tension Rods" + }, + { + "assembly": "airframe_assy", + "component": "95229a420", + "qty": 1, + "uom": "ea", + "notes": "Nose Cone Tension Rods" + }, + { + "assembly": "airframe_assy", + "component": "3018t23", + "qty": 1, + "uom": "ea", + "notes": "Nose Cone Recovery Anchor" + }, + { + "assembly": "airframe_assy", + "component": "92994a029", + "qty": 1, + "uom": "ea", + "notes": "Nose Cone Eyebolt Nut" + }, + { + "assembly": "airframe_assy", + "component": "20545t38", + "qty": 1, + "uom": "ea", + "notes": "Round shipping tube with press-on end caps, 4\" ID, 36\" inside length" + }, + { + "assembly": "avbay_assy", + "component": "avby_hf_3994", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "avbay_assy", + "component": "avby_sb_4160", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "90281a102", + "qty": 6, + "uom": "ea", + "notes": "Coupler tension rods, Recovery Bulkhead Studs (Note: add'l qty 8 on Chamber)" + }, + { + "assembly": "airframe_assy", + "component": "3043t643", + "qty": 3, + "uom": "ea", + "notes": "Shock Cord Anchor Point" + }, + { + "assembly": "avbay_assy", + "component": "92580a328", + "qty": 2, + "uom": "ea", + "notes": "Avbay rods" + }, + { + "assembly": "airframe_assy", + "component": "8982k402", + "qty": 4, + "uom": "ea", + "notes": "Fin brackets" + }, + { + "assembly": "airframe_assy", + "component": "spcr_400sd_250c0313c", + "qty": 4, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "1125t412", + "qty": 1, + "uom": "ea", + "notes": "Fins (stock)" + }, + { + "assembly": "airframe_assy", + "component": "91255a839", + "qty": 1, + "uom": "pack", + "notes": "Fin Bracket Attachment to Tank" + }, + { + "assembly": "airframe_assy", + "component": "7631a82", + "qty": 1, + "uom": "ea", + "notes": "Coupler Shimming" + }, + { + "assembly": "airframe_assy", + "component": "97654a620", + "qty": 1, + "uom": "pack", + "notes": "Rail Button Screws" + }, + { + "assembly": "airframe_assy", + "component": "rlbtn_a_1515_250c", + "qty": 2, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "rlbtn_b_1515_250c", + "qty": 2, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "tmplt_af_lwr_416", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "tmplt_af_upr_416", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "plg_dril_af_upr_416", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "tmplt_fin_brkt_1x1_aft", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "airframe_assy", + "component": "tmplt_fin_brkt_1x1_fwd", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "avbay_assy", + "component": "egtmr_qrk", + "qty": 2, + "uom": "ea", + "notes": "Deployment controller" + }, + { + "assembly": "recovery_assy", + "component": "cnpy_cht_86in", + "qty": 1, + "uom": "ea", + "notes": "Alternate main chute canopy (saves $55.94)" + }, + { + "assembly": "recovery_assy", + "component": "crd_kvlr_250lb", + "qty": 1, + "uom": "ea", + "notes": "Main chute canopy shroud lines" + }, + { + "assembly": "recovery_assy", + "component": "rec_cht_drg_36", + "qty": 1, + "uom": "ea", + "notes": "Drogue chute" + }, + { + "assembly": "recovery_assy", + "component": "rec_tnsc_3klb", + "qty": 50, + "uom": "ft", + "notes": "Shock cord" + }, + { + "assembly": "recovery_assy", + "component": "rec_prt_16", + "qty": 2, + "uom": "ea", + "notes": "Main and Drogue Chute Protector" + }, + { + "assembly": "avbay_assy", + "component": "avby_sld_225x600_6xm4c", + "qty": 1, + "uom": "ea", + "notes": "Avbay sled" + }, + { + "assembly": "recovery_assy", + "component": "bkhd_rec_399_250c_2x170we", + "qty": 3, + "uom": "ea", + "notes": "Avbay bulkheads, recovery bulkhead" + }, + { + "assembly": "avbay_assy", + "component": "avby_cr_4r", + "qty": 2, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "avbay_assy", + "component": "avby_mt_250r_m4c", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "avbay_assy", + "component": "avby_bh_2x9v250r_m4c", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "avbay_assy", + "component": "93505a211", + "qty": 4, + "uom": "ea", + "notes": "Altimeter mounting" + }, + { + "assembly": "avbay_assy", + "component": "91755a152_91755a200", + "qty": 1, + "uom": "pack", + "notes": "Altimeter Insulation (McMaster description: self-retaining washer)" + }, + { + "assembly": "recovery_assy", + "component": "93135a013", + "qty": 1, + "uom": "pack", + "notes": "Altimeter mounting, Shear Pins, Nose Cone" + }, + { + "assembly": "avbay_assy", + "component": "7712k511", + "qty": 2, + "uom": "ea", + "notes": "Altimeter Power" + }, + { + "assembly": "avbay_assy", + "component": "9v_batt", + "qty": 1, + "uom": "pack", + "notes": "Altimeter Power" + }, + { + "assembly": "recovery_assy", + "component": "8947t26", + "qty": 4, + "uom": "ea", + "notes": "Shock cord attachment" + }, + { + "assembly": "avbay_assy", + "component": "20ga_rbp", + "qty": 1, + "uom": "ea", + "notes": "Avbay wiring" + }, + { + "assembly": "gse_control", + "component": "dtus_fsi6x_10ch", + "qty": 1, + "uom": "ea", + "notes": "Controller" + }, + { + "assembly": "gse_control", + "component": "az18080602_03", + "qty": 1, + "uom": "pack", + "notes": "Switch covers, 12mm mount dia" + }, + { + "assembly": "gse_control", + "component": "62586", + "qty": 1, + "uom": "ea", + "notes": "Electrical power" + }, + { + "assembly": "gse_control", + "component": "20ga_rbp", + "qty": 1, + "uom": "set", + "notes": "Leftover from avbay" + }, + { + "assembly": "gse_control", + "component": "lcatmc1569", + "qty": 1, + "uom": "ea", + "notes": "Ignition switch" + }, + { + "assembly": "gse_control", + "component": "drok_090600afa", + "qty": 1, + "uom": "ea", + "notes": "Battery to receiver" + }, + { + "assembly": "gse_control", + "component": "gardner_bender_gsw_17", + "qty": 1, + "uom": "ea", + "notes": "GSE power switch" + }, + { + "assembly": "gse_control", + "component": "7159k3", + "qty": 1, + "uom": "ea", + "notes": "Electrical outlets (IGN/SRV)" + }, + { + "assembly": "gse_control", + "component": "7526k23_7526k53", + "qty": 1, + "uom": "ea", + "notes": "Outlet cover" + }, + { + "assembly": "gse_control", + "component": "srv_3pin_1mxt", + "qty": 1, + "uom": "pack", + "notes": "Leftovers from Valves" + }, + { + "assembly": "gse_fill", + "component": "ds3225", + "qty": 1, + "uom": "ea", + "notes": "Fill valve actuator" + }, + { + "assembly": "gse_fill", + "component": "4112t22", + "qty": 1, + "uom": "ea", + "notes": "Ox fill valve, lever handle" + }, + { + "assembly": "gse_fill", + "component": "20lbalvlv_326", + "qty": 1, + "uom": "ea", + "notes": "Price includes approx shipping cost" + }, + { + "assembly": "gse_fill", + "component": "79215a673", + "qty": 1, + "uom": "ea", + "notes": "Nitrous tank adapter" + }, + { + "assembly": "gse_fill", + "component": "79215a672", + "qty": 1, + "uom": "ea", + "notes": "Nitrous tank adapter" + }, + { + "assembly": "gse_fill", + "component": "4468k811", + "qty": 1, + "uom": "ea", + "notes": "Tank to valve hose" + }, + { + "assembly": "gse_fill", + "component": "50675k162", + "qty": 2, + "uom": "ea", + "notes": "Tank outlet and valve inlet" + }, + { + "assembly": "gse_fill", + "component": "50785k92", + "qty": 1, + "uom": "ea", + "notes": "Tank adapter to flare fitting" + }, + { + "assembly": "gse_fill", + "component": "50785k273", + "qty": 1, + "uom": "ea", + "notes": "Fill bulkhead fitting" + }, + { + "assembly": "gse_fill", + "component": "97149a370", + "qty": 1, + "uom": "ea", + "notes": "Bulkhead fitting spacer nut" + }, + { + "assembly": "gse_fill", + "component": "50785k222", + "qty": 1, + "uom": "ea", + "notes": "Nitrous gauge tee" + }, + { + "assembly": "gse_fill", + "component": "5485k22", + "qty": 1, + "uom": "ea", + "notes": "Gauge tee to valve" + }, + { + "assembly": "gse_fill", + "component": "9396t32", + "qty": 1, + "uom": "ea", + "notes": "Valve to fill line" + }, + { + "assembly": "gse_fill", + "component": "9396t31", + "qty": 2, + "uom": "ea", + "notes": "Fill line bulkhead" + }, + { + "assembly": "gse_fill", + "component": "50785k272", + "qty": 1, + "uom": "ea", + "notes": "Fill line bulkhead fitting" + }, + { + "assembly": "gse_fill", + "component": "3846k8", + "qty": 1, + "uom": "ea", + "notes": "Supply pressure indication" + }, + { + "assembly": "gse_fill", + "component": "90272a194", + "qty": 1, + "uom": "pack", + "notes": "Leftovers from Valves (handle connector screw)" + }, + { + "assembly": "gse_fill", + "component": "90480a009", + "qty": 1, + "uom": "pack", + "notes": "Leftovers from Valves (handle connector screw)" + }, + { + "assembly": "gse_fill", + "component": "98689a113", + "qty": 1, + "uom": "pack", + "notes": "Leftovers from Valves (servo mounting)" + }, + { + "assembly": "gse_fill", + "component": "92148a160", + "qty": 1, + "uom": "pack", + "notes": "Leftovers from Valves (servo mounting)" + }, + { + "assembly": "gse_fill", + "component": "9685t3", + "qty": 1, + "uom": "ea", + "notes": "Leftover from Fluid System Components" + }, + { + "assembly": "gse_fill", + "component": "pla_fil_2kg", + "qty": 1, + "uom": "ea", + "notes": "Leftover from Valves (valve actuator housing)" + }, + { + "assembly": "gse_fill", + "component": "sabv_mount_v2", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "gse_fill", + "component": "sabv_hcon_v2", + "qty": 1, + "uom": "ea", + "notes": "3D printed" + }, + { + "assembly": "gse_enclosure", + "component": "91111", + "qty": 1, + "uom": "ea", + "notes": "GSE enclosure" + }, + { + "assembly": "gse_enclosure", + "component": "9489t519", + "qty": 2, + "uom": "ea", + "notes": "Battery restraints" + }, + { + "assembly": "gse_enclosure", + "component": "emma_kites_ek5580", + "qty": 1, + "uom": "ea", + "notes": "QD clip tether / shroud lines for tent tarp chute" + }, + { + "assembly": "gse_integration", + "component": "7768k26", + "qty": 2, + "uom": "ea", + "notes": "Purge system (optional)" + }, + { + "assembly": "gse_integration", + "component": "9396t61", + "qty": 1, + "uom": "ea", + "notes": "Splices purge line into fill line (only if purge valve included)" + }, + { + "assembly": "gse_integration", + "component": "16_3_10ftxt", + "qty": 2, + "uom": "ea", + "notes": "Donor cable stock for servo and ignition cables" + }, + { + "assembly": "gse_integration", + "component": "alg_clp_ins", + "qty": 1, + "uom": "set", + "notes": "QTY 2 clips crimped to ignition cable for e-match connection" + }, + { + "assembly": "gse_integration", + "component": "qdc_750x125flg_270mb_o007", + "qty": 1, + "uom": "ea", + "notes": "Fill line QD to vehicle" + }, + { + "assembly": "gse_integration", + "component": "9452k15", + "qty": 1, + "uom": "ea", + "notes": "QD barb seal" + }, + { + "assembly": "gse_integration", + "component": "brkt_egse_2x8c", + "qty": 2, + "uom": "ea", + "notes": "Mounts receiver and relay module" + }, + { + "assembly": "gse_integration", + "component": "cvr_ant_2x8c", + "qty": 1, + "uom": "ea", + "notes": "Protects receiver antennas on outer back face" + }, + { + "assembly": "gse_integration", + "component": "92141a029", + "qty": 2, + "uom": "ea", + "notes": "Eye bolt hardware" + }, + { + "assembly": "gse_integration", + "component": "91102a750", + "qty": 2, + "uom": "ea", + "notes": "Eye bolt hardware" + }, + { + "assembly": "gse_integration", + "component": "95505a601", + "qty": 2, + "uom": "ea", + "notes": "Eye bolt hardware" + }, + { + "assembly": "gse_integration", + "component": "92295a100", + "qty": 4, + "uom": "ea", + "notes": "Attach toggle switch covers to transmitter" + }, + { + "assembly": "gse_integration", + "component": "zip_tie_188w", + "qty": 2, + "uom": "ea", + "notes": "Battery restraint and wire management (2-3 used for battery)" + }, + { + "assembly": "airframe_assy", + "component": "fin_std_250t_4x250c", + "qty": 4, + "uom": "ea", + "notes": "Cut from 1125T412 plywood stock" + }, + { + "assembly": "airframe_assy", + "component": "fin_bracket_assy", + "qty": 4, + "uom": "ea", + "notes": "Built by the Fin Bracket Subassembly procedure" + }, + { + "assembly": "fin_bracket_assy", + "component": "8982k402", + "qty": 1, + "uom": "ea", + "notes": "Drilled per fin bracket template" + }, + { + "assembly": "fin_bracket_assy", + "component": "fin_std_250t_4x250c", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "fin_bracket_assy", + "component": "spcr_400sd_250c0313c", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "vehicle", + "component": "tank_assy", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "vehicle", + "component": "fluid_assy", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "vehicle", + "component": "valve_assy_grp", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "vehicle", + "component": "tca_assy", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "vehicle", + "component": "igniter_assy", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "vehicle", + "component": "airframe_assy", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "vehicle", + "component": "recovery_assy", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "vehicle", + "component": "avbay_assy", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "valve_assy_grp", + "component": "sabv_assy", + "qty": 2, + "uom": "ea", + "notes": "Fuel and oxidizer valves \u2014 built by the valve assembly procedure" + }, + { + "assembly": "gse_fill", + "component": "sabv_assy", + "qty": 1, + "uom": "ea", + "notes": "Remote N2O fill valve \u2014 same assembly as the vehicle valves" + }, + { + "assembly": "gse", + "component": "gse_control", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "gse", + "component": "gse_fill", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "gse", + "component": "gse_enclosure", + "qty": 1, + "uom": "ea", + "notes": null + }, + { + "assembly": "gse", + "component": "gse_integration", + "qty": 1, + "uom": "ea", + "notes": null + } + ] +} diff --git a/src/opal/seed_data/sphinx/procedures.json b/src/opal/seed_data/sphinx/procedures.json new file mode 100644 index 0000000..668a7ad --- /dev/null +++ b/src/opal/seed_data/sphinx/procedures.json @@ -0,0 +1,3879 @@ +{ + "attribution": "Demo content adapted from HCR-5100 \u2014 Mojave Sphinx Build, Integration, and Launch Guidebook, Half Cat Rocketry, published under the GPL.", + "procedures": [ + { + "key": "valve_assembly", + "name": "Servo-Actuated Ball Valve Assembly", + "description": "Builds one SABV-04FF servo-actuated ball valve assembly: a DS3225 servo and a 4112T22 brass ball valve on a printed mount, coupled through a printed handle connector. The vehicle consumes two (fuel and oxidizer); the GSE fill system consumes a third (HCR-5100 OP 1).", + "type": "build", + "output": { + "part": "sabv_assy", + "qty": 1 + }, + "kit": [ + { + "part": "sabv_mount_v2", + "qty": 1.0 + }, + { + "part": "sabv_hcon_v2", + "qty": 1.0 + }, + { + "part": "pla_fil_2kg", + "qty": 1.0 + }, + { + "part": "4112t22", + "qty": 1.0 + }, + { + "part": "ds3225", + "qty": 1.0 + }, + { + "part": "93475a210", + "qty": 1.0 + }, + { + "part": "92855a310", + "qty": 1.0 + }, + { + "part": "90272a194", + "qty": 1.0 + }, + { + "part": "90480a009", + "qty": 1.0 + }, + { + "part": "zip_tie_188w", + "qty": 1.0 + }, + { + "part": "91280a044", + "qty": 1.0 + }, + { + "part": "90591a141", + "qty": 1.0 + }, + { + "part": "98689a113", + "qty": 1.0 + }, + { + "part": "92148a160", + "qty": 1.0 + } + ], + "ops": [ + { + "title": "Assemble Servo-Actuated Ball Valve", + "instructions": "Build one servo-actuated ball valve assembly (PN SABV-04FF) by mounting a DS3225 25kg servo and a 4112T22 compact high-pressure brass ball valve into a 3D-printed Servo Valve Mount, coupling the servo horn to the valve handle via a printed handle connector. (HCR-5100 OP 1.)", + "workcenter": "BENCH", + "step_kit": [ + { + "part": "sabv_mount_v2", + "qty": 1.0 + }, + { + "part": "sabv_hcon_v2", + "qty": 1.0 + }, + { + "part": "pla_fil_2kg", + "qty": 1.0 + }, + { + "part": "4112t22", + "qty": 1.0 + }, + { + "part": "ds3225", + "qty": 1.0 + }, + { + "part": "93475a210", + "qty": 1.0 + }, + { + "part": "92855a310", + "qty": 1.0 + }, + { + "part": "90272a194", + "qty": 1.0 + }, + { + "part": "90480a009", + "qty": 1.0 + }, + { + "part": "zip_tie_188w", + "qty": 1.0 + }, + { + "part": "91280a044", + "qty": 1.0 + }, + { + "part": "90591a141", + "qty": 1.0 + }, + { + "part": "98689a113", + "qty": 1.0 + }, + { + "part": "92148a160", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Verify and secure servo horn orientation", + "instructions": "Confirm the servo horn is installed in the correct orientation: connect the servo to your control device and verify the horn points the correct direction in both valve-open and valve-closed commanded states. Secure the horn to the spur gear with the M3 Phillips screw included with the servo (replacement PN 92000A76 if lost or damaged).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install servo into Servo Valve Mount", + "instructions": "Install the DS3225 servo into the Servo Valve Mount using 4x M4x16 hex head screws (92180A044; step text prints 91280A044), 8x 98689A113 flat washers, 4x 92148A160 lock washers, and 4x 90591A141 nuts. Fully tighten the 2 screws closest to the back of the mount before installing the outer 2 fasteners; socket head screws (PN 90128A215) may be substituted to remove this ordering dependency.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mount handle connector on servo horn", + "instructions": "Move the servo horn to point out from the Servo Valve Mount (valve closed position). Mount the Servo Valve Handle Connector onto the servo horn using the M3 socket head cap screw and washer, installed in the threaded hole closest to the end of the horn.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Insert ball valve into mount", + "instructions": "Move the servo horn back to the valve-open position, parallel to the back face of the mount. Insert the 4112T22 ball valve into the mount so the handle slides into the Servo Valve Handle Connector and the indicated holes align.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Pin valve handle to connector", + "instructions": "Move the servo horn and valve handle to the valve-closed position, then install 1x 90272A194 #8-32 screw with 1x 92148A160 lock washer and 1x 90480A009 nut through the handle connector and ball valve handle. The screw should fit through the existing hole in the valve handle; if it does not, drill as necessary to achieve a clearance fit.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Secure valve body with zip tie", + "instructions": "Secure the valve in place with a nylon zip tie passed through the small rectangular slot in the Servo Valve Mount. The zip tie is not critical to valve function but reduces movement of the valve body during actuation.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Bag and label completed assemblies", + "instructions": "Bag and label the completed servo-actuated ball valve assembly with PN SABV-04FF and set aside for integration.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + } + ] + }, + { + "key": "fin_brackets", + "name": "Fin Bracket Subassembly", + "description": "Builds the four identical fin bracket assemblies: drilled aluminum angle, plywood fin, and printed spacers with optional aerodynamic tips. The vehicle consumes all four at thrust structure integration (HCR-5100 OP 8).", + "type": "build", + "output": { + "part": "fin_bracket_assy", + "qty": 4 + }, + "kit": [ + { + "part": "8982k402", + "qty": 8.0 + }, + { + "part": "fin_std_250t_4x250c", + "qty": 4.0 + }, + { + "part": "91306a279", + "qty": 16.0 + }, + { + "part": "92141a029", + "qty": 32.0 + }, + { + "part": "91102a750", + "qty": 16.0 + }, + { + "part": "95505a601", + "qty": 16.0 + }, + { + "part": "tmplt_fin_brkt_1x1_aft", + "qty": 1.0 + }, + { + "part": "tmplt_fin_brkt_1x1_fwd", + "qty": 1.0 + } + ], + "ops": [ + { + "title": "Fin Bracket Fabrication", + "instructions": "Build four identical fin bracket assemblies: drill aluminum angle brackets, mount plywood fins with 1/4\"-20 hardware, and optionally fit printed aerodynamic bracket tips. All steps performed four times; quantities listed are for four complete assemblies. (HCR-5100 OP 8, pp. 191-195; PDF 200-204.)", + "workcenter": "SHOP", + "step_kit": [ + { + "part": "8982k402", + "qty": 8.0 + }, + { + "part": "fin_std_250t_4x250c", + "qty": 4.0 + }, + { + "part": "91306a279", + "qty": 16.0 + }, + { + "part": "92141a029", + "qty": 32.0 + }, + { + "part": "91102a750", + "qty": 16.0 + }, + { + "part": "95505a601", + "qty": 16.0 + }, + { + "part": "tmplt_fin_brkt_1x1_aft", + "qty": 1.0 + }, + { + "part": "tmplt_fin_brkt_1x1_fwd", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Drill fin bracket aluminum angles", + "instructions": "Drill 1X .332\" and 7X .281\" holes in QTY 4 pieces of 1\"X1\"X.125\" aluminum angle. The 3D printed templates may be used to mark the hole locations; if not used, refer to the drawing. A drill press or mill is recommended. If using a drill press or hand drill, use an automatic center punch at each hole location prior to drilling to prevent the bit from walking.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mount fins to brackets", + "instructions": "Mount QTY 4 FIN-STD-250T-4X250C fins (or alternate/custom fins) to the drilled aluminum angle brackets using QTY 4 91306A279 screws, QTY 8 92141A029 flat washers, QTY 4 91102A750 lock washers, and QTY 4 95505A601 hex nuts per fin. Tighten 16X screws approximately 1/4 turn past full compression of the lock washer.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install aerodynamic bracket tips (optional)", + "instructions": "Optional: if the leading edges of the aluminum angle fin brackets have not been tapered, install QTY 4 AERO-1X1X125-TIP printed aerodynamic bracket tips onto the aluminum angle. Deburr the edges of the aluminum angle with a deburring knife or file prior to installation. Tips are designed as a press fit; if loose, bond with an adhesive suitable for non-porous materials such as epoxy. If tight, bevel the aluminum angle edges with a file, or carefully warm the aluminum angle and/or printed part with a heat gun to soften the plastic.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + } + ] + }, + { + "key": "vehicle_assembly", + "name": "Mojave Sphinx Vehicle Assembly", + "description": "Section V build sequence for the Mojave Sphinx flight vehicle: tank, thrust chamber, thrust structure, avionics bay, nose cone, airframe, and recovery integration (HCR-5100 OPs 2-7 and 9-13). Servo valve and fin bracket subassemblies arrive as kit parts from their own build procedures.", + "type": "build", + "output": { + "part": "vehicle", + "qty": 1 + }, + "ops": [ + { + "title": "Drill Tank Tube", + "instructions": "Prepare the 4\" OD x 1/8\" wall x 36\" 6061-T6 aluminum tank tube: square the ends, drill 8x 11/32\" bolt holes at each end using printed drill jigs, deburr all edges, and drill the 1.2mm nitrous static vent orifice at 14.25 inches from one end. A vertical mill with indexing head may substitute for the printed jigs. (HCR-5100 OP 2.)", + "workcenter": "SHOP", + "step_kit": [ + { + "part": "9056k423", + "qty": 1.0 + }, + { + "part": "drljg_tank_8x625od_bshng", + "qty": 2.0 + } + ], + "sub_steps": [ + { + "title": "Verify tank tube ends square", + "instructions": "Ensure the tank tube ends are cut square to within approximately +/-.020. Check by wrapping a strip of paper around the tube and aligning the edge to create a straight line along the cylinder surface. If needed, use a file or trim off a short section (<.25\") with a hacksaw or bandsaw to square the ends.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "ends_square", + "type": "text", + "label": "Tube ends square within +/-.020 in", + "required": true + } + ] + } + }, + { + "title": "Install tank drill jigs", + "instructions": "Press the 2x tank drill jigs onto the tube ends until the tube contacts the lip of the jig. Ensure each jig is snug and cannot slide or rotate; secure with tape if necessary. Optionally mark each hole location with a marker or automatic center punch to keep the drill tip in place when starting holes.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Drill 8x bolt holes per end", + "instructions": "Drill 8x holes with an 11/32\" drill bit in each end of the tank tube, using the jig to align the bit. Be careful not to let the drill bit strike the inside surface on the far side of the tube; a drill stop may be used but is not required. A drill press is recommended, though a hand drill may be used.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Deburr hole edges inside tube", + "instructions": "Deburr the hole edges on the inside of the tube so sharp edges or burrs will not damage O-rings when inserting the piston and bulkhead. A reversible countersink such as the Noga RC2000 is recommended; any standard countersink tool may be used to break the interior edges by hand. If sandpaper or abrasive pads are used, ensure all motion is circumferential \u2014 axial motion creates scratches perpendicular to the seal and increases likelihood of leakage.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Deburr tube end interiors", + "instructions": "Deburr the interior edges of both ends of the tank using a deburring knife or fine rounded file. This reduces risk of seal damage and improves ease of inserting the piston and bulkhead.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mark vent orifice location", + "instructions": "Measure 14.25 inches from one end of the tank, in line with one set of tank bolt holes, and mark this location with a permanent marker.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Drill 1.2mm nitrous static vent orifice", + "instructions": "At the marked location, drill a 1.2mm diameter hole through the tank wall to create the nitrous static vent orifice. An automatic center punch may be used prior to drilling to prevent the bit from walking. Mark the vent location with a permanent marker or paint pen to assist identification.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mark rail guide line", + "instructions": "Mark a line between one pair of bolt holes 90 degrees from the vent location and label it \"RAIL GUIDES\". This ensures the vent points to one side of the rail for maximum visibility.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Fuel Bulkhead Subassembly", + "instructions": "Assemble the fuel tank bulkhead: install sealed stud-anchor screws with fluorosilicone sealing washers, populate the printed nut ring with weld nuts and hex nuts inside the tank interface ring, and plumb the servo-actuated fuel valve to the bulkhead via an aluminum pipe nipple plus a fitting stack ending in a 5/16\" 37-degree flared adapter for the fuel downcomer line. (HCR-5100 OP 3.)", + "workcenter": "BENCH", + "step_kit": [ + { + "part": "sabv_assy", + "qty": 1.0 + }, + { + "part": "bkhd_fl_o238_4npt2x250c", + "qty": 1.0 + }, + { + "part": "inrg_200s_8x3125c8x250c", + "qty": 1.0 + }, + { + "part": "ntrg_45c20d_200e", + "qty": 1.0 + }, + { + "part": "94579a550", + "qty": 8.0 + }, + { + "part": "95505a601", + "qty": 8.0 + }, + { + "part": "90357a013", + "qty": 2.0 + }, + { + "part": "91367a952", + "qty": 2.0 + }, + { + "part": "90264a435", + "qty": 2.0 + }, + { + "part": "92141a029", + "qty": 2.0 + }, + { + "part": "44665k137", + "qty": 1.0 + }, + { + "part": "50785k43", + "qty": 1.0 + }, + { + "part": "5485k31", + "qty": 1.0 + }, + { + "part": "50785k35", + "qty": 1.0 + }, + { + "part": "50675k435", + "qty": 1.0 + }, + { + "part": "50785k27", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Install sealed screws and coupling nuts in bulkhead", + "instructions": "Install 2x 90357A013 ultra-low-profile socket head screws, each with one 91367A952 sealing washer under the head, into the fuel bulkhead (FBKHD-D375-O238-4NPT). Thread the screws into 2x 90264A435 coupling nuts with 2x 92141A029 washers between the coupling nuts and bulkhead. Tighten screws until snug, then 1/2 additional turn. Note: excessive torque may cause sealing washers to be extruded.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Populate printed nut ring", + "instructions": "Place 8x 94579A550 5/16-24 weld nuts and 8x 95505A601 1/4-20 hex nuts into the printed nut ring (NTRG-3125WN-25HN-8X). A strip of scotch tape may be used to retain the nuts prior to insertion into the machined integration ring, but adhesive must not be used to bond the nuts \u2014 they must float within their cutouts to accommodate hole misalignment. Tape may cover the edges of the hex nuts but not the holes.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Place tank interface ring on bulkhead", + "instructions": "Place the Tank Interface Ring onto the outer face of the bulkhead. The interface ring MUST be installed prior to integrating the valve assembly onto the bulkhead.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Insert nut ring into interface ring", + "instructions": "Insert the fully populated nut ring into the tank interface ring (INRG-375-3125C-250C-8X) so the 5/16-24 weld nuts align with the larger holes on the smaller-diameter portion of the ring. The location of the \"missing\" centering tab does not matter for the fuel bulkhead subassembly.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Tape and thread pipe nipple into fuel valve", + "instructions": "Apply 2-3 clockwise wraps of PTFE tape to both ends of the aluminum pipe nipple (step text: 44665K122; gather list: 44665K137) and thread one end into the port of the SABV-14NPT-FF valve, noting the orientation of the valve bracket. Tighten only to hand-tight at this time.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Thread valve/nipple into bulkhead port", + "instructions": "Thread the other end of the aluminum pipe nipple into the offset 1/4 NPT port on the fuel bulkhead (FBKHD-D375-O238-4NPT). Tighten until snug, then rotate no more than one full turn until the valve assembly aligns with the diameter of the bulkhead.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install downcomer fitting stack", + "instructions": "Install 1x 50785K27 straight adapter, 1x 50785K43 male/female elbow, 1x 5485K31 reducing adapter, 1x 50785K35 female elbow, and 1x 50675K435 flared fitting adapter. Apply 2-3 wraps of PTFE tape onto each set of male threads before installation. When installing elbow fittings, tighten until snug, then no more than one full turn until the fitting points in the correct direction. The 50785K27 straight adapter prevents excess slack length in the fuel downcomer line.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Recovery Bulkhead Integration", + "instructions": "Mount the recovery/avionics bay bulkhead onto the forward fuel bulkhead: thread two 7-inch 1/4\"-20 studs into the coupling nuts on the fuel bulkhead, fit a 1\" ID U-bolt through the recovery bulkhead as the recovery attachment point, then secure the bulkhead onto the studs with hex nuts, flat washers, and split lock washers. (HCR-5100 OP 4.)", + "workcenter": "BENCH", + "step_kit": [ + { + "part": "95505a601", + "qty": 6.0 + }, + { + "part": "92141a029", + "qty": 6.0 + }, + { + "part": "91102a750", + "qty": 4.0 + }, + { + "part": "3043t643", + "qty": 1.0 + }, + { + "part": "bkhd_rec_399_250c_2x170we", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Thread studs into coupling nuts", + "instructions": "Thread 2x 91025A568 studs into the coupling nuts until they contact the ends of the screws installed through the forward bulkhead or reach the ends of the stud threads. Tighten to hand-tight; hardware installed in a later step will prevent rotation.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install U-bolt in recovery bulkhead", + "instructions": "Remove the hex nuts and mounting plate from the 3043T643 U-bolt, then thread the included hex nuts all the way to the end of the thread, tightening until snug. Place 2x 91102A750 split lock washers and 2x 92141A029 flat washers onto the U-bolt ends, insert the U-bolt into the recovery bulkhead holes, and secure with the included mounting plate and 2x 95505A601 hex nuts. Tighten both hex nuts approximately 1/4 turn past full compression of the lock washers.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mount recovery bulkhead onto studs", + "instructions": "Thread 2x 95505A601 hex nuts about 3/4\" onto the threaded studs and place 2x 92141A029 flat washers on top of the nuts. Place the recovery bulkhead with U-bolt onto the studs and secure with an additional 2x 95505A601 hex nuts, 2x 92141A029 flat washers, and 2x 91102A750 split lock washers. Tighten the hex nuts on each stud approximately 1/4 turn past full compression of the lock washers.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Oxidizer Bulkhead Subassembly", + "instructions": "Assemble the oxidizer tank bulkhead: install the oxidizer fill check valve with push-to-connect fitting into the 1/8 NPT port, plumb the servo-actuated oxidizer valve to the 1/4 NPT port via an aluminum pipe nipple with a 3/8\" 37-degree flared outlet adapter, populate the nut ring and interface ring, and build the quick-disconnect fill line from high-pressure nylon tubing. (HCR-5100 OP 5.)", + "workcenter": "BENCH", + "step_kit": [ + { + "part": "sabv_assy", + "qty": 1.0 + }, + { + "part": "bkhd_ox_o238_8npt4npt", + "qty": 1.0 + }, + { + "part": "inrg_200s_8x3125c8x250c", + "qty": 1.0 + }, + { + "part": "ntrg_45c20d_200e", + "qty": 1.0 + }, + { + "part": "94579a550", + "qty": 8.0 + }, + { + "part": "95505a601", + "qty": 8.0 + }, + { + "part": "44665k137", + "qty": 1.0 + }, + { + "part": "50675k163", + "qty": 1.0 + }, + { + "part": "7768k21", + "qty": 1.0 + }, + { + "part": "9396t31", + "qty": 2.0 + }, + { + "part": "50785k41", + "qty": 1.0 + }, + { + "part": "9685t3", + "qty": 1.0 + }, + { + "part": "qdc_750x125flg_281fb8npt", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Install fill check valve with push-to-connect fitting", + "instructions": "Apply 2-3 clockwise wraps of PTFE tape to the male threads of 1x 9396T31 push-to-connect fitting (1/8 NPT) and 1x 7768K21 brass check valve. Thread the push-to-connect fitting into the check valve and tighten until snug, then thread the check valve into the 1/8 NPT port on the oxidizer tank bulkhead (OBKHD-D375-O238-4NPT) and tighten until snug. Once snug, continue tightening until one corner of the check valve points radially outward (<1/6 turn required) to ensure clearance to the nut retaining ring.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Tape and thread pipe nipple into oxidizer valve", + "instructions": "Apply 2-3 clockwise wraps of PTFE tape to both ends of the aluminum pipe nipple (step text: 44665K122; gather list: 44665K137) and thread one end into the port of the SABV-14NPT-FF valve, noting the orientation of the valve bracket. Tighten only to hand-tight at this time.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install flared outlet adapter in valve", + "instructions": "Apply 2-3 clockwise wraps of PTFE tape to the threads of 1x 50675K163 37 degree flared adapter and thread it into the other side of the SABV-14NPT-FF valve, noting the orientation of the valve bracket. Tighten the fitting until snug, 1-3 turns from finger tight.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Populate printed nut ring", + "instructions": "Place 8x 94579A550 5/16-24 weld nuts and 8x 95505A601 1/4-20 hex nuts into the printed nut ring (NTRG-3125WN-25HN-8X). A strip of scotch tape may be used to retain the nuts prior to insertion into the machined integration ring, but adhesive must not be used to bond the nuts \u2014 they must float within their cutouts to accommodate hole misalignment.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Place tank interface ring on bulkhead", + "instructions": "Place the Tank Interface Ring onto the outer face of the bulkhead. The interface ring MUST be installed prior to integrating the valve assembly onto the bulkhead.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Insert nut ring, clock to check valve", + "instructions": "Insert the fully populated nut ring into the tank interface ring (INRG-375-3125C-250C-8X) so the 5/16-24 weld nuts align with the larger holes on the smaller-diameter portion of the ring. Align the nut ring and interface ring so the oxidizer fill check valve aligns with the section that does NOT have a centering tab.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Thread valve/nipple into bulkhead port", + "instructions": "Thread the aluminum pipe nipple connected to the oxidizer valve into the 1/4 NPT port on the oxidizer tank bulkhead (OBKHD-D375-O238-4NPT). Tighten until snug, then rotate no more than one full turn until the valve assembly aligns with the diameter of the bulkhead.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Assemble quick-disconnect fitting stack", + "instructions": "Apply 2-3 clockwise wraps of PTFE tape to the threads of 1x 50785K41 brass elbow fitting and 1x 9396T31 push-to-connect fitting. Assemble the two fittings and the QDC-750x125FLG-281FB-8NPT QD fitting, tightening all threads until snug, 1-3 turns past finger tight.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Cut and mark fill line tubing", + "instructions": "Cut a section of 9685T3 tubing 3.625-3.875 inches long. Mark the tube at 0.56 inches from each end using a permanent marker.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect fill line tubing", + "instructions": "Insert one end of the tube into the push-to-connect fitting on the oxidizer fill check valve until the mark aligns with the top face of the fitting or the tube is fully seated. Then connect the push-to-connect fitting on the QD assembly to the other end of the tube, inserting up to the mark or until fully seated.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Tank Assembly", + "instructions": "Assemble the integral fuel/oxidizer tank: grease and install O-rings on both bulkhead subassemblies and the piston, insert the piston to depth and both bulkhead subassemblies into the drilled tank tube with correct clocking (fuel line raceway, vent, QD, and rail guide positions), bolt the retaining rings, and connect the braided fuel hose to the fuel valve outlet. (HCR-5100 OP 6.)", + "workcenter": "BENCH", + "step_kit": [ + { + "part": "bkhd_fl_o238_4npt2x250c", + "qty": 1.0 + }, + { + "part": "bkhd_ox_o238_8npt4npt", + "qty": 1.0 + }, + { + "part": "tank_36l_047v2x8x313c", + "qty": 1.0 + }, + { + "part": "pstn_2xo238_250t20", + "qty": 1.0 + }, + { + "part": "9452k226", + "qty": 4.0 + }, + { + "part": "91255a378", + "qty": 12.0 + }, + { + "part": "4468k858", + "qty": 1.0 + }, + { + "part": "50675k135", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Grease O-rings", + "instructions": "Apply a liberal amount of grease to the 5x Buna-N O-rings (source says \"-230\"; gather list specifies 4x 9452K226 -238 O-rings). Set aside on a clean surface.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install O-rings on bulkheads", + "instructions": "Install one greased Buna-N O-ring into the O-ring gland on each of the 2x bulkhead subassemblies.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install O-rings on piston", + "instructions": "Install 2x greased Buna-N O-rings onto the fuel tank piston.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Insert piston to depth", + "instructions": "Insert the piston with O-rings into the tank, with the face containing the 1/4-20 threaded extraction hole facing the forward end where the fuel bulkhead will be inserted (the end closest to the vent orifice). Take care not to damage the O-rings; apply slow, even pressure to compress the seals into the tube. Using a marked piece of PVC pipe, push the piston down until the top face sits 12.125-12.25 inches from the end of the tube.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "piston_depth", + "type": "number", + "label": "Piston top face depth from tube end (spec 12.125-12.25 in)", + "required": true + } + ] + } + }, + { + "title": "Insert oxidizer bulkhead subassembly", + "instructions": "Insert the oxidizer bulkhead/valve subassembly into the aft end of the tank (farthest from the vent orifice), noting the orientation relative to the designated rail guide position. Apply slow, even pressure to compress the seal without damaging the O-ring. Use the tank interface ring to ensure the bulkhead seats at the correct depth, aligning the ring bolt holes to the tank bolt holes; if match drilled, ensure the clocking marks are aligned.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Insert fuel bulkhead subassembly", + "instructions": "Insert the fuel bulkhead/valve subassembly into the forward end of the tank, noting the orientation relative to the designated rail guide position. Apply slow, even pressure to compress the seal without damaging the O-ring. Use the tank interface ring to ensure the bulkhead seats at the correct depth, aligning the ring bolt holes to the tank bolt holes; if match drilled, ensure the clocking marks are aligned.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install 8x forward retaining screws", + "instructions": "Install 8x 91255A378 screws through the tank and retaining ring bolt holes at the forward end (fuel valve side), threading into the weld nuts in the nut retaining ring. Insert all screws before tightening to allow freedom of movement for alignment, then tighten to snug with a 3/16\" hex key. These fasteners are loaded primarily in shear and do not need to be extremely tight.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install 4x aft retaining screws", + "instructions": "Install 4x 91255A378 screws through the tank and retaining ring bolt holes at the aft end (oxidizer valve side), skipping every other hole and ensuring screw locations are aligned relative to the valve and rail guide positions per the reference images. The remaining bolt holes will secure the thrust structure to the tank later. Leave all 4 screws on this end slightly loose.", + "caution": "CAUTION: The tank cannot be pressurized until all 8X screws are installed. If the tank is to be pressure tested prior to integration of the fin bracket/thrust structure assembly, install an additional 4X 91255A378 screws and tighten all screws to snug.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Install flared union in fuel hose", + "instructions": "Install 1x 50675K135 flared union fitting into one end of the 4468K858 hose. Tighten to wrench tight (approx. 80 ft-lbs).", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "fuel_hose_union_torque", + "type": "number", + "label": "Fuel hose union torque (spec approx. 80 ft-lbs)", + "required": true + } + ] + } + }, + { + "title": "Connect fuel hose to fuel valve outlet", + "instructions": "Connect the other end of the 4468K858 hose to the flared fitting on the fuel valve outlet at the forward end of the tank. Tighten to wrench tight (approx. 100-150 in-lbs). The fuel line may be secured to the tank with zip ties and/or tape; a lengthwise strip of aluminum tape is recommended, but do not fully cover the fuel line yet \u2014 the fuel valve servo wire must also be secured in a similar manner.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "fuel_hose_valve_torque", + "type": "number", + "label": "Fuel hose to valve torque (spec approx. 100-150 in-lbs)", + "required": true + } + ] + } + } + ], + "strict_sequence": true, + "depends_on": [ + 2, + 3, + 4 + ] + }, + { + "title": "Thrust Chamber Assembly", + "instructions": "Assemble the thrust chamber: plumb the injector with flared adapters and igniter-port pipe nipple, install the injector with -141 silicone O-rings into the combustion chamber, seat the throat insert with garter spring into the nozzle, trim the graphite gasket, and clamp the injector/chamber/nozzle stack between two steel flanges using eight 1/4\"-20 tie-rod studs with thrust-mount angle brackets. (HCR-5100 OP 7.)", + "workcenter": "SHOP", + "step_kit": [ + { + "part": "nzzl_45c20d_200e", + "qty": 1.0 + }, + { + "part": "thrt_45c20d_100t", + "qty": 1.0 + }, + { + "part": "cmbr_200di_250b_525l", + "qty": 1.0 + }, + { + "part": "injc_2x8npt_38npt_250t20_2xo230_basic", + "qty": 1.0 + }, + { + "part": "50675k164", + "qty": 1.0 + }, + { + "part": "50675k435", + "qty": 1.0 + }, + { + "part": "50785k171", + "qty": 1.0 + }, + { + "part": "50785k91", + "qty": 1.0 + }, + { + "part": "9396k79", + "qty": 2.0 + }, + { + "part": "90213a101", + "qty": 1.0 + }, + { + "part": "94095k114", + "qty": 1.0 + }, + { + "part": "flng_st_25t_8x250c", + "qty": 2.0 + }, + { + "part": "brkt_90a_125t_2x250c", + "qty": 8.0 + }, + { + "part": "95505a601", + "qty": 16.0 + }, + { + "part": "92141a029", + "qty": 16.0 + }, + { + "part": "91102a750", + "qty": 8.0 + }, + { + "part": "90281a102", + "qty": 8.0 + }, + { + "part": "90015a410", + "qty": 1.0 + }, + { + "part": "9368t14", + "qty": 1.0 + }, + { + "part": "8284n57", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Install injector fittings", + "instructions": "Apply 2-3 clockwise wraps of PTFE tape to the male threads of the 50675K164 and 50675K435 flared adapters and both ends of the 50785K171 pipe nipple. Install the fittings into the injector ports, with 50785K91 on the upper end of the pipe nipple; tighten all fittings to snug, 1-3 turns past finger-tight. Install the 50675K164 fitting into the center port (oxidizer inlet) first for tool access; a strap wrench is recommended to grip the injector, and pliers or vise-grips should be used for the pipe nipple (marring is acceptable and non-detrimental).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install external retaining ring on injector", + "instructions": "Install 1x external retaining ring (step text: 97633A450; gather list: 90213A101) into the upper-most groove on the injector, ensuring it is fully seated in the groove. Clocking of the ring does not matter.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install sleeve bearing and truss head screw", + "instructions": "Install 1x 9368T14 bronze sleeve bearing and 1x 90015A410 truss head slotted screw. Tighten firmly, ensuring the bearing is compressed to the injector face.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install -141 O-rings on injector", + "instructions": "Liberally grease 2x 9396K79 -141 high-temperature O-rings and install into the O-ring glands on the injector. The deepest groove (2nd from the injector face) is the fuel manifold \u2014 do NOT install an O-ring in this groove.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Seat injector in combustion chamber", + "instructions": "Install the injector assembly into the larger bore in the combustion chamber, applying slow, even pressure to compress the seals without damaging the O-rings. When fully seated, the external retaining ring should contact the forward face of the chamber; a small gap is acceptable at this time, as the injector will be drawn in by the tie-rods in a later step.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Fit garter spring to throat insert", + "instructions": "Place the 8284N57 garter spring into the groove of the THRT-45C20D-100T nozzle throat insert.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Press throat insert into nozzle", + "instructions": "Coat the outside of the throat insert, including the spring, in a thin layer of grease, then install the insert into the nozzle. A wooden dowel or plastic rod may be used to press or mallet the insert into the bore until the spring seats in the retaining groove. The spring prevents the insert from dislodging during handling; no soft seal is used \u2014 chamber pressure loading against the bottom face of the nozzle bore is sufficient to minimize blow-by.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Trim and place graphite gasket", + "instructions": "Using a razor knife, trim the inner edge of the 94095K114 graphite gasket to an approximately 2-inch inner diameter, leaving at least 3/8\" of width on all sides; slight protrusion into the combustion chamber is non-detrimental. Place the gasket onto the flat face of the nozzle. The gasket has a thin stainless steel foil layer in the middle, which cuts easily with a sharp blade.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Stack flanges, chamber, and nozzle", + "instructions": "Place one FLNG-ST-25T-8X250C flange over the external retaining ring on the injector, place the second flange around the nozzle, and stack the injector/chamber on top of the nozzle and gasket. Tip: use a strip of aluminum tape to hold the nozzle concentric to the chamber.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install first 4 tie-rods through upper flange", + "instructions": "Thread 4x 95505A601 hex nuts onto 4x 90281A102 threaded studs with approximately .28\" of thread protruding. Place 1x 91102A750 split lock washer under each nut, followed by 1x 92141A029 flat washer per rod, then insert the rods through the upper flange. Two flange holes must align with the fuel inlet and igniter port; install the rods adjacent to and +/-90 degrees from the injector fittings as shown.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Retain lower flange on tie-rods", + "instructions": "Place 1x 92141A029 flat washer and 1x 95505A601 hex nut onto the lower end of each rod to retain the lower flange. Thread the nuts on until the flange seats against the nozzle, with approximately equal thread protrusion on each rod end. Tighten to snug; full tightening happens in a later step.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install remaining 4 rods with mounting brackets", + "instructions": "Thread 4x 95505A601 hex nuts onto the remaining 4x 90281A102 studs with approximately .15\" of thread protruding; add 1x 91102A750 split lock washer and 1x 92141A029 flat washer per rod. Place 1x BRKT-90A-125T-2X250C bracket onto each rod with the longer tab extending vertically upward, and insert the rods through the unoccupied holes in both flanges. Place an additional 4x brackets onto the rods at the aft end in the same orientation, and secure with 4x 95505A601 hex nuts and 4x 92141A029 flat washers; lock washers are not required on the aft end.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Final-tighten all 8 tie-rods", + "instructions": "Tighten the nuts on all 8 rods until the lock washers are fully compressed, then approximately 1/4 additional turn, ensuring roughly equal thread protrusion on both ends of each rod. Tighten in a star pattern and repeat the pattern until all nuts remain tight. Some visible deflection of the FLNG-ST-25T-8X250C flange plates is normal and does not indicate a problem.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Thrust Structure & Feedline Integration", + "instructions": "Attach the four fin bracket assemblies to the thrust chamber and propellant tank, install oxidizer and fuel feedline hoses at specified torques, and route/secure the valve servo wiring. (Guidebook pp. 196-204, PDF 205-213.) (HCR-5100 OP 9.)", + "workcenter": "BENCH", + "step_kit": [ + { + "part": "fin_bracket_assy", + "qty": 4.0 + }, + { + "part": "spcr_400sd_250c0313c", + "qty": 4.0 + }, + { + "part": "91306a279", + "qty": 8.0 + }, + { + "part": "91255a839", + "qty": 4.0 + }, + { + "part": "92141a029", + "qty": 32.0 + }, + { + "part": "91102a750", + "qty": 16.0 + }, + { + "part": "95505a601", + "qty": 16.0 + }, + { + "part": "4468k031", + "qty": 1.0 + }, + { + "part": "4468k865", + "qty": 1.0 + }, + { + "part": "srv_3pin_1mxt", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Attach fin subassemblies at upper thrust chamber brackets", + "instructions": "Connect the QTY 4 fin subassemblies to the thrust chamber mounting brackets. Use QTY 1 91306A279 screw with QTY 2 92141A029 flat washers, QTY 1 91102A750 lock washer, and QTY 1 95505A601 hex nut at each of the upper thrust chamber brackets.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Attach fin subassemblies at lower thrust chamber brackets", + "instructions": "On the lower thrust chamber brackets, stack QTY 1 91102A750 lock washer followed by QTY 4 92141A029 flat washers under the head of the screw, then insert the screw through aligned holes in the aluminum angle fin bracket and the thrust chamber mounting bracket. Place an additional QTY 1 92141A029 flat washer onto the screw on the back side of the thrust chamber bracket, then secure with QTY 1 95505A601 hex nut.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect oxidizer hose to oxidizer valve outlet", + "instructions": "Connect one end of QTY 1 4468K865 hose to the outlet fitting on the oxidizer valve. Tighten to wrench tight (approx. 250-300 in-lbf).", + "caution": "Be sure to back up the oxidizer valve outlet fitting to prevent torquing the valve assembly itself.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Align thrust chamber to tank assembly", + "instructions": "Align the thrust chamber and fin brackets relative to the tank assembly; the fuel inlet fitting on the injector should be on the same side as the fuel line downcomer previously installed on the tank.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Align fin bracket holes and insert spacers", + "instructions": "Align the holes in the fin brackets with the corresponding 5/16\" and 1/4\" holes in the tank and interface ring, then slide QTY 1 SPCR-400SD-250C-313C spacer between the fin bracket and tank.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install fin bracket to tank screws", + "instructions": "As each spacer is inserted, install QTY 1 91306A279 screw with QTY 1 92141A029 washer into each of the 1/4\" holes, and QTY 1 91255A839 screw into each of the 5/16\" holes. Thread the screws into the nuts captured in the nut retaining ring, and tighten all 4X 91306A279 and 4X 91255A839 screws to snug.", + "caution": "Excessive torque may damage the printed nut retaining ring.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect oxidizer hose to injector", + "instructions": "Connect the free end of the 4468K865 hose to the oxidizer inlet fitting on the injector. Torque to moderately tight (approx. 250-300 in-lbf). Route the line forming a loose spiral to take up the excess length.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect fuel hose to downcomer union", + "instructions": "Connect one end of QTY 1 4468K031 hose to the union fitting on the fuel line downcomer installed on the tank assembly. Torque to wrench tight (approximately 100-150 in-lbf).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect fuel hose to injector", + "instructions": "Connect the other end of QTY 1 4468K031 hose to the fuel inlet fitting on the injector. Torque to wrench tight (approximately 100-150 in-lbf). Route the line tucking behind the oxidizer line.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Route and secure servo wiring", + "instructions": "Once the feedlines are fully installed, plug the fuel valve servo lead into the female end of the SRV-3PIN-1MXT servo extension cable. Route the extension down the side of the tank alongside the fuel downcomer, securing it to the fuel hose with 3-4 zip ties or loops of tape. Plug the other end of the servo extension and the oxidizer valve servo lead into the SRV-Y-HRNS servo Y-harness. Secure all connections using electrical tape to prevent accidental unplugging during handling. Bundle excess wire length and secure to one of the thrust structure brackets using tape and/or zip ties, with the male end of the Y-harness free.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ], + "depends_on": [ + 6 + ] + }, + { + "title": "Avionics Bay Assembly", + "instructions": "Assemble the printed avionics bay coupler with threaded reinforcing rods, fabricate and populate the altimeter sled with two Eggtimer Quark dual-deploy altimeters and 9V batteries, wire for twist-and-tape external power switching, install the U-bolt bulkhead, and close out the bay. (Guidebook pp. 205-216, PDF 214-225.) (HCR-5100 OP 10.)", + "workcenter": "ELEC", + "step_kit": [ + { + "part": "avby_hf_3994", + "qty": 2.0 + }, + { + "part": "avby_sb_4160", + "qty": 1.0 + }, + { + "part": "95505a601", + "qty": 20.0 + }, + { + "part": "92141a029", + "qty": 20.0 + }, + { + "part": "91102a750", + "qty": 16.0 + }, + { + "part": "90281a102", + "qty": 4.0 + }, + { + "part": "92580a328", + "qty": 2.0 + }, + { + "part": "3043t643", + "qty": 2.0 + }, + { + "part": "avby_cr_4r", + "qty": 2.0 + }, + { + "part": "bkhd_rec_399_250c_2x170we", + "qty": 2.0 + }, + { + "part": "avby_sld_225x600_6xm4c", + "qty": 1.0 + }, + { + "part": "avby_mt_250r_m4c", + "qty": 1.0 + }, + { + "part": "avby_bh_2x9v250r_m4c", + "qty": 1.0 + }, + { + "part": "91280a044", + "qty": 6.0 + }, + { + "part": "98689a113", + "qty": 12.0 + }, + { + "part": "92148a160", + "qty": 6.0 + }, + { + "part": "90591a141", + "qty": 6.0 + }, + { + "part": "93505a211", + "qty": 4.0 + }, + { + "part": "91755a152_91755a200", + "qty": 8.0 + }, + { + "part": "egtmr_qrk", + "qty": 2.0 + }, + { + "part": "7712k511", + "qty": 2.0 + }, + { + "part": "20ga_rbp", + "qty": 1.0 + }, + { + "part": "9v_batt", + "qty": 2.0 + } + ], + "sub_steps": [ + { + "title": "Assemble coupler halves and switch band on threaded rods", + "instructions": "Assemble QTY 2 avionics bay coupler halves and QTY 1 AVBY-SB-4160 switch band. Thread QTY 1 95505A601 hex nut with QTY 1 91102A750 lock washer and QTY 1 92141A029 flat washer onto one end of each of the QTY 4 90281A102 threaded rods, then insert the rods through the sleeves of the printed coupler components. Secure each rod with an additional QTY 1 92141A029 flat washer, QTY 1 91102A750 lock washer, and QTY 1 95505A601 hex nut. Ensure equal thread protrusion such that the rods do not extend past the end of the coupler. Tighten the nuts on all 4X rods only until the lock washers are fully compressed to flat.", + "caution": "Excessive torque may damage the printed components.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Fabricate avionics bay sled (if not prefabricated)", + "instructions": "If the avionics bay sled has not been prefabricated, cut a 2.25\" x 6\" rectangle from 1/4\" plywood.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mark and drill sled mounting holes", + "instructions": "Place the AVBY-MT-250R-M4C sled mount and AVBY-BH-2X9V-250R-M4C battery holder onto the plywood sled and mark the position of 6X holes using the printed parts. Remove printed parts and drill holes using a #16 or similarly-sized drill bit to create clearance holes suitable for M4 fasteners. It is recommended to insert QTY 2 92580A328 threaded rods through the sleeves to ensure alignment prior to drilling mounting holes.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mark and drill altimeter mounting holes", + "instructions": "Place QTY 2 Eggtimer Quark altimeters, assembled per the manufacturer's directions, on the avionics bay sled. Exact position is not critical, but the terminal blocks should be approximately centered on the sled to ensure sufficient clearance to the coupler reinforcing rods. Mark the hole positions using a fine point pen or mechanical pencil, then remove the altimeters and drill 4X holes with a #50 drill bit (2-56 tap drill size).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install hex standoffs into sled", + "instructions": "Thread QTY 4 93505A211 hex standoffs into the holes in the plywood sled. Once fully seated, back off the standoffs 2-4 turns, apply a small dab of cyanoacrylate (superglue) gel or other suitable adhesive, then re-engage threads fully. It is not necessary to tap the holes; the standoffs will cut their own threads due to the softness of the plywood. The 90604A303 screws may be temporarily threaded into the standoffs to allow a phillips screwdriver to be used for installation; hold the standoffs with a 3/16\" wrench or pliers while removing the screws.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mount sled mount and battery holder", + "instructions": "Mount the AVBY-MT-250R-M4C sled mount and AVBY-BH-2X9V-250R-M4C battery holder onto the plywood sled using QTY 6 92180A044 M4 hex head screws, QTY 12 98689A113 M4 flat washers, QTY 6 92148A160 M4 split lock washers, and QTY 6 90591A141 M4 hex nuts. Tighten all fasteners approximately 1/4 turn past full compression of the lock washer.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mount altimeters onto standoffs", + "instructions": "Mount the QTY 2 Eggtimer Quark altimeters onto the hex standoffs using QTY 4 90604A303 2-56 screws and QTY 8 91755A152 plastic washers. Each hole on the altimeter boards should have a plastic washer on each side to protect against the risk of electrical shorts. Tighten the screws only to snug using a small phillips screwdriver.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Wire altimeters for charges and external power", + "instructions": "Connect wiring to the QTY 2 Eggtimer Quark altimeters per the manufacturer's directions, using 20GA-RBP 20 gauge stranded red/black pair silicone-coated wire and QTY 2 7712K511 9V battery connectors or equivalents. Each altimeter should have two wires for the drogue charge, two wires for the main charge, and two wires for connecting/disconnecting power externally. Mark the wires to differentiate between main and drogue on each altimeter. The avionics bay is designed for \"twist and tape\" style power connection/disconnection: a pair of wires with stripped ends is twisted together to supply power and wrapped with electrical tape; to disconnect, the tape is removed and the wires separated and insulated. These wires should be long enough to be inserted through the 2X large holes in the avionics bay switch band prior to installing the sled (~10\").", + "caution": "The \"twist and tape\" power wires should NOT be pushed inside the avionics bay, as this will make them inaccessible should power need to be disconnected after an aborted launch. The wires will not be harmed by protruding 2-4\" out of the rocket during flight, so long as they are securely twisted and wrapped in electrical tape.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Install and secure 9V batteries", + "instructions": "Install QTY 2 9V batteries into the battery holder. Snap the 7712K511 battery connectors onto the terminals of each battery (ensuring that power is disconnected from the altimeter), then secure the batteries with QTY 2 zip ties routed through the slots/notches on the battery holder and over the tops of both battery connectors.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mount sled assembly onto threaded rods", + "instructions": "Insert QTY 2 92580A328 threaded rods through the sleeves of the avionics bay sled assembly, with equal protrusion of the rods from each end. Place QTY 1 92141A029 flat washer followed by QTY 1 95505A601 hex nut onto each end of each rod to secure the sled assembly in place. Tighten only to finger-tight, or lightly snug using a wrench. It is recommended to strain relieve wires using zip ties.", + "caution": "Excessive torque may damage the printed components.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Install U-bolt on avionics bay bulkhead", + "instructions": "Remove the hex nuts and mounting plate included with the 3043T643 U-bolt, then thread the included hex nuts all the way to the end of the thread, tightening until snug. Place QTY 2 91102A750 split lock washers and QTY 2 92141A029 flat washers onto the ends of the U-bolt, then insert the U-bolt into the holes in the avionics bay bulkhead and centering ring. Secure with the included mounting plate and QTY 2 95505A601 hex nuts. Tighten both hex nuts approximately 1/4 turn past full compression of the lock washers.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install aft bulkhead on sled rods and mark AFT", + "instructions": "Thread QTY 1 95505A601 hex nut approximately .875\" onto the lower end of each of the avionics bay sled threaded rods. Insert the rods through the holes on the avionics bay bulkheads. Install QTY 1 92141A029 flat washer followed by QTY 1 91102A750 split lock washer and QTY 1 95505A601 hex nut onto each rod. Tighten the hex nuts on each rod against each other until the lock washers are fully compressed to secure the bulkhead in place. The rods should protrude approximately .25-.30\" from the outer hex nuts. Mark this bulkhead with \"AFT\" using a permanent marker, and insert the 2X pairs of drogue charge wires from the altimeters through the wire exit holes.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install sled into coupler and feed power wires", + "instructions": "Install the avionics bay sled with attached bulkhead into the avionics bay coupler in the orientation shown. During (or prior to) installing the sled, feed each pair of the \"twist and tape\" altimeter power switch wires through the large holes in the avionics bay switch band from the inside. A piece of masking or electrical tape may be placed onto each pair of power wires to prevent the ends from accidentally slipping back into the avionics bay.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install forward bulkhead", + "instructions": "Place the second avionics bay bulkhead over the protruding ends of the threaded rods and secure with QTY 2 92141A029 flat washers, QTY 2 91102A750 split lock washers, and QTY 2 95505A601 hex nuts. Tighten the hex nuts just until the lock washers are fully compressed.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Nose Cone Assembly", + "instructions": "Stack the printed nose cone sections on nylon threaded tension rods, fit the eye bolt and shock cord in the tip, secure the tip with nylon shear screws, and capture the airframe attachment nuts in the shoulder. (Guidebook pp. 217-224, PDF 226-233.) (HCR-5100 OP 11.)", + "workcenter": "BENCH", + "step_kit": [ + { + "part": "nscn_sldr_4x250r8x832hnc", + "qty": 1.0 + }, + { + "part": "nscn_sc1_4x250r", + "qty": 1.0 + }, + { + "part": "nscn_sc2_4x250r", + "qty": 1.0 + }, + { + "part": "nscn_tip_4x250r250hnc", + "qty": 1.0 + }, + { + "part": "98831a028", + "qty": 2.0 + }, + { + "part": "95505a601", + "qty": 8.0 + }, + { + "part": "91102a750", + "qty": 9.0 + }, + { + "part": "95229a420", + "qty": 8.0 + }, + { + "part": "92141a029", + "qty": 8.0 + }, + { + "part": "93135a013", + "qty": 4.0 + }, + { + "part": "3018t23", + "qty": 1.0 + }, + { + "part": "92994a029", + "qty": 1.0 + }, + { + "part": "90480a009", + "qty": 8.0 + }, + { + "part": "rec_tnsc_3klb", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Cut nylon threaded rods", + "instructions": "Cut QTY 2 98831A028 nylon threaded rods into equal lengths of 12 inches each, for a total of four pieces.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Stack nose cone components", + "instructions": "Stack QTY 1 NSCN-SLDR-4X250R-8X832HNC nose cone shoulder, QTY 1 NSCN-SC1-4X250R nose cone Section 1, and QTY 1 NSCN-SC2-4X250R nose cone Section 2.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Insert tension rods", + "instructions": "Insert QTY 4 12-inch sections of 98831A028 nylon threaded rod into the sleeves of the printed nose cone components, with approximately .50\" protrusion from the bottom of the nose cone shoulder.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install and tighten rod nuts", + "instructions": "Install QTY 1 95505A601 hex nut with QTY 1 91102A750 lock washer and QTY 1 95229A420 flat washer onto each end of each nylon threaded rod, with approximately equal thread protrusion from each nut. Tighten the 4X nuts on the bottom of the nose cone shoulder just until the lock washers are fully compressed. Note: excessive thread protrusion will interfere with the nose cone tip installed on a later step.", + "caution": "Excessive torque may strip the threads of the nylon threaded rods.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Install eye bolt into nose cone tip", + "instructions": "Place QTY 1 91102A750 lock washer and QTY 8 92141A029 washers onto the shank of QTY 1 3018T23 eye bolt. Insert QTY 1 92994A029 cap nut into the hex on the NSCN-TIP-4X250R-250HNC nose cone tip, and thread the eye bolt with washers into the cap nut until snug, ensuring that the eye is oriented between the counterbores. Washers may be added or subtracted as needed. The eye bolt is prevented from rotating by other features within the nose cone assembly, and does not need to be especially tight.", + "caution": "Excessive torque may damage the hex on the printed nose cone tip.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Tie shock cord to eye bolt", + "instructions": "Tie one end of a 24\" section of TNSC-3KLB shock cord to the eye bolt using a non-slip loop knot. The loop should be large enough that the body of the knot sits 2-3 inches behind the eye bolt when pulled taut. Ensure the knot is sufficiently compact to fit through the opening in the NSCN-SC2-4X250R nose cone section.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Place nose cone tip", + "instructions": "Place the nose cone tip with eye bolt onto the lower portion of the nose cone assembly, aligning the tabs and slots so that the tension rods and nuts sit within the counterbores.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Secure tip with nylon screws", + "instructions": "Secure the nose cone tip to Section 2 of the nose cone using QTY 4 93135A013 nylon screws. The screw holes are nominally an interference fit to allow the screw to be pushed or threaded in without tapping the hole, and retained by friction. If the screws fit loosely, adhesive may be used. If the holes are too small, they may be drilled as needed, and optionally tapped with a #2-56 thread on the NSCN-SC2-4X250R tabs.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install captive nuts in shoulder", + "instructions": "Place QTY 8 90480A009 hex nuts into the hexagonal cutouts on the interior of the nose cone shoulder. The nuts should snap in past the retention tabs with moderate force. If the nuts cannot be pushed past the retention tabs, lightly shave down the tips of the tabs using a razor blade. If the tolerances are too loose for the nuts to be adequately retained, a small strip of 7631A82 aluminum foil tape or similar can be placed over each hexagonal cutout.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Tie shoulder-end shock cord loop", + "instructions": "Tie the protruding section of shock cord into a second non-slip loop knot, so that the loop hangs out of the nose cone shoulder.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Airframe Fabrication", + "instructions": "Cut the 4\" cardboard shipping tube into upper and lower airframe tubes, mark and drill mounting/nose-cone/shear-pin holes using printed templates and sacrificial backing plugs, cut the fuel line slot, and verify hole alignment against mating assemblies. Alternate tubes must be 4\" ID; thin-walled (~.062\") mailing tubes are not recommended (recommended tube wall ~.080\"; .125\" and .250\" heavy duty tubes acceptable at cost of weight and drag). (Guidebook pp. 225-228, PDF 234-237.) (HCR-5100 OP 12.)", + "workcenter": "SHOP", + "step_kit": [ + { + "part": "20545t38", + "qty": 1.0 + }, + { + "part": "tmplt_af_lwr_416", + "qty": 1.0 + }, + { + "part": "tmplt_af_upr_416", + "qty": 1.0 + }, + { + "part": "plg_dril_af_upr_416", + "qty": 1.0 + } + ], + "sub_steps": [ + { + "title": "Cut tube into upper and lower airframe", + "instructions": "Cut QTY 1 20545T38 shipping tube into two pieces of equal length (18 inches each). Designate one as the upper airframe tube (20545T38-AF-UPR) and one as the lower airframe tube (20545T38-AF-LWR). Ensure the cut end is flat and clean.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mark lower airframe holes and insert backing plug", + "instructions": "Use the TMPLT-AF-LWR-416 template to mark the positions of the airframe mounting bolt holes and fuel line slot on the lower airframe tube. Then insert the PLG-DRL-AF-LWR sacrificial backing plug into the tube so that it sits flush with the end at which the holes are to be drilled, to prevent or reduce delamination of the cardboard layers during drilling. The plug is intentionally oversized to preload against the inside of the tube.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Drill lower airframe mounting holes", + "instructions": "Using a 9/32\" drill bit, drill 7X holes in the airframe tube in the locations marked using the template. Once all holes are drilled, remove and discard the sacrificial backing plug.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Cut fuel line slot", + "instructions": "Cut the slot along the templated line using a razor blade and/or a very fine toothed saw. Tip: it is recommended -but not required- to reinforce the edges of the slot, holes, and tube ends using a thin cyanoacrylate glue or epoxy that will soak into the cardboard.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Cover lower airframe with aluminum tape (optional)", + "instructions": "Optional step: cover the lower airframe tube with aluminum tape in lengthwise strips with approximately 1/4\" of overlap. Burnish the tape down with a flexible plastic card to remove wrinkles. This is primarily for visual effect, and provides minimal if any structural reinforcement.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mark upper airframe nose cone holes and insert backing plug", + "instructions": "Use the TMPLT-AF-UPR1-416 template to mark the positions of the nose cone screw holes on the forward end of the upper airframe tube. Then insert the PLG-DRL-AF-UPR1 sacrificial backing plug into the tube so that it sits flush with the end at which the holes are to be drilled, to prevent or reduce delamination during drilling. The plug is intentionally oversized to preload against the inside of the tube.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Drill nose cone screw holes", + "instructions": "Using a 3/16\" drill bit, drill 8X holes in the airframe tube in the locations marked using the template. Once all holes are drilled, remove and discard the sacrificial backing plug.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mark and drill shear pin holes", + "instructions": "Use the TMPLT-AF-UPR2-416 template to mark the positions of the shear pin holes on the aft end of the upper airframe tube. A sacrificial backing plug is not required due to the small diameter of these holes. Using a Number 50 (.070\") drill bit, drill 4X holes in the locations marked using the template. Once drilled, use sandpaper and/or a razor blade to clean up any raised material on the inside of the tube. The holes are intentionally undersized to allow press-fitting the shear pins through the cardboard.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Verify lower airframe hole alignment", + "instructions": "Verify alignment of the lower airframe mounting bolt holes by sliding the 20545T38-AF-LWR airframe tube over the upper tank interface ring. Ensure that 7X 91306A279 screws are able to be installed simultaneously into the nuts retained in the interface ring. No screw is installed in the hole that aligns with the fuel line slot.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "verified_7x_91306a279_screws_install", + "type": "text", + "label": "Verified: 7X 91306A279 screws install simultaneously into interface ring nuts (lower airframe)", + "required": true + } + ] + } + }, + { + "title": "Verify nose cone screw hole alignment", + "instructions": "Verify alignment of the upper airframe nose cone screw holes by inserting the nose cone assembly into the forward end of the upper airframe tube and installing 8X 90272A194 screws into the nuts retained in the nose cone shoulder.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "verified_8x_90272a194_screws_install", + "type": "text", + "label": "Verified: 8X 90272A194 screws install into nose cone shoulder nuts (upper airframe)", + "required": true + } + ] + } + }, + { + "title": "Verify shear pin hole alignment", + "instructions": "Verify alignment of the upper airframe tube shear pin holes by sliding the upper airframe tube onto the forward end of the avionics bay coupler, so that the 4X shear pin holes are positioned over the shear pin slots in the avionics bay bulkhead. Install 4X 93135A013 nylon screws into the shear pin holes by press fitting, or turning with a screwdriver, although the cardboard holes are not intended to be threaded. If needed, the shear pins may be retained for flight using a small patch of tape over the head.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "verified_4x_93135a013_shear_pins_seat", + "type": "text", + "label": "Verified: 4X 93135A013 shear pins seat through upper airframe into avionics bay bulkhead slots", + "required": true + } + ] + } + } + ] + }, + { + "title": "Recovery System & Airframe Integration", + "instructions": "Rig the main parachute shroud lines (if not using a COTS chute), build the main and drogue recovery harnesses from 24 ft shock cord sections, connect chutes and parachute protectors via quick links to the nose cone, avionics bay, and tank U-bolts, then install the nose cone, mount the lower airframe, and fit rail buttons. (Guidebook pp. 229-234, PDF 238-243.) (HCR-5100 OP 13.)", + "workcenter": "BENCH", + "step_kit": [ + { + "part": "rec_tnsc_3klb", + "qty": 1.0 + }, + { + "part": "cnpy_cht_86in", + "qty": 1.0 + }, + { + "part": "crd_kvlr_250lb", + "qty": 1.0 + }, + { + "part": "rec_prt_16", + "qty": 2.0 + }, + { + "part": "rec_cht_drg_36", + "qty": 1.0 + }, + { + "part": "8947t26", + "qty": 4.0 + }, + { + "part": "90272a194", + "qty": 8.0 + }, + { + "part": "97654a620", + "qty": 2.0 + }, + { + "part": "92141a029", + "qty": 2.0 + }, + { + "part": "rlbtn_a_1515_250c", + "qty": 2.0 + }, + { + "part": "rlbtn_b_1515_250c", + "qty": 2.0 + } + ], + "sub_steps": [ + { + "title": "Cut main parachute shroud lines", + "instructions": "Skip steps 1-3 if using a COTS parachute. Cut 3 sections of CRD-KVLR-250LB Kevlar cord, each approximately 28 feet in length. Ensure all pieces are consistent in length to within +/-3 inches. These will form the shroud lines for the main parachute.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Tie shroud lines to canopy", + "instructions": "Tie the ends of each piece of Kevlar cord to the loops sewn onto the corners of the hexagonal tarp, with minimal excess length after the knot. Ensure the ends of each section of cord are tied to adjacent loops, rather than spanning across the canopy to the loop on the opposite side. Any type of knot may be used; a non-slip loop knot is recommended.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Gather shroud lines and tie attachment loop", + "instructions": "Gather the Kevlar cords from the middle of each section. Allow the canopy to bunch into a triangle, so that the sewn loops onto which the shroud lines are tied all align with each other, ensuring the shroud lines are equal in length. Once the sewn loops are aligned, tie a loop at the midsection of the bundled shroud lines using an overhand knot. This will be used to connect the shroud lines to the shock cord via a quick link.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Cut shock cord", + "instructions": "Cut the remaining 48ft of shock cord into two equal lengths of 24 ft each.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Designate recovery harnesses", + "instructions": "Designate one section of shock cord as the main recovery harness, and one section as the drogue recovery harness.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Tie main parachute loop in main harness", + "instructions": "On the main recovery harness, tie a loop approximately 6 to 8 feet from one end, using an overhand knot on a doubled section of cord. Alternate methods such as a Rigger's Loop Knot or Alpine Butterfly may also be used if desired. This loop will be used to connect the main parachute.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect main harness to nose cone", + "instructions": "Connect the end of the main recovery harness closest to the loop to the short section of shock cord on the nose cone assembly by tying a non-slip loop knot, with the loop passing through the existing non-slip loop knot on the nose cone.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect main harness to avionics bay U-bolt", + "instructions": "Pass the other end of the main recovery harness through the 20545T38-AF-UPR airframe tube, and tie a second non-slip loop knot on the end of the harness. Connect this non-slip loop knot to the U-bolt on the forward end of the Avionics Bay Coupler assembly with QTY 1 8947T26 quick link. Close the quick link and tighten hand tight. A wrench may be used, but is not required.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Tie drogue harness end loops", + "instructions": "Tie a non-slip loop knot on each end of the drogue recovery harness.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect drogue harness to tank recovery bulkhead", + "instructions": "Connect one end of the drogue recovery harness to the U-bolt on the recovery bulkhead on the tank assembly with QTY 1 8947T26 quick link. Close the quick link and tighten hand tight. A wrench may be used, but is not required.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect drogue harness to avionics bay (quick link open)", + "instructions": "Connect the other end of the drogue recovery harness to the U-bolt on the lower end of the Avionics Bay Coupler assembly with QTY 1 8947T26 quick link. Do not close the quick link at this time.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Attach drogue parachute", + "instructions": "Tie the center of the shroud lines on the REC-CHT-DRG-36 drogue parachute into a 2-3-inch diameter loop using an overhand knot. Then place this loop onto the 8947T26 quick link on the drogue recovery harness, where it connects to the Avionics Bay Coupler.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Attach drogue parachute protector and close quick link", + "instructions": "Place the reinforced slit of QTY 1 REC-PRT-16 parachute protector onto the 8947T26 quick link on the drogue recovery harness, where it connects to the Avionics Bay Coupler. Close the quick link and tighten hand tight. A wrench may be used, but is not required.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect main parachute to harness loop", + "instructions": "Using QTY 1 8947T26 quick link, connect the loop at the base of the main parachute shroud lines to the loop on the main recovery harness, 6-8 feet from the nose cone end. Do not close the quick link at this time.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Attach main parachute protector and close quick link", + "instructions": "Place the reinforced slit of QTY 1 REC-PRT-16 parachute protector onto the 8947T26 quick link at the base of the main parachute shroud lines, where it connects to the main recovery harness. Close the quick link and tighten hand tight. A wrench may be used, but is not required.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install nose cone into upper airframe", + "instructions": "Install the Nose Cone Assembly into the forward end of the 20545T38-AF-UPR airframe tube, and secure with QTY 8 90272A194 screws, threaded into the nuts in the nose cone shoulder.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Mount lower airframe tube to tank interface ring", + "instructions": "Mount the lower airframe tube onto the forward tank interface ring using QTY 6 91306A279 screws and QTY 6 92141A029 washers in the locations shown. Tighten to snug using a 5/32\" hex key.", + "caution": "Excessive torque may damage the printed nut retaining ring.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Assemble rail buttons", + "instructions": "Place QTY 2 RLBTN-A-1515-250C, QTY 2 RLBTN-B-1515-250C, and QTY 2 92141A029 washers onto QTY 2 97654A620 screws (one of each per screw) to form a rail button assembly.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install forward rail button", + "instructions": "Install one rail button assembly into the unoccupied hole/nut on the forward tank interface ring, 90 degrees from the fuel line. Tighten to very slightly snug, so that the rail button can rotate with minor resistance. It does not need to spin freely.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install aft rail button", + "instructions": "Install the second rail button assembly into the hole/nut on the lower tank interface ring, 90 degrees from the fuel line (aligned with the upper rail button). Tighten to very slightly snug, so that the rail button can rotate with minor resistance. It does not need to spin freely.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ], + "depends_on": [ + 9, + 10 + ] + } + ], + "kit": [ + { + "part": "90272a194", + "qty": 8.0 + }, + { + "part": "90480a009", + "qty": 8.0 + }, + { + "part": "91280a044", + "qty": 6.0 + }, + { + "part": "90591a141", + "qty": 6.0 + }, + { + "part": "98689a113", + "qty": 12.0 + }, + { + "part": "92148a160", + "qty": 6.0 + }, + { + "part": "9056k423", + "qty": 1.0 + }, + { + "part": "drljg_tank_8x625od_bshng", + "qty": 2.0 + }, + { + "part": "bkhd_fl_o238_4npt2x250c", + "qty": 2.0 + }, + { + "part": "inrg_200s_8x3125c8x250c", + "qty": 2.0 + }, + { + "part": "ntrg_45c20d_200e", + "qty": 2.0 + }, + { + "part": "94579a550", + "qty": 16.0 + }, + { + "part": "95505a601", + "qty": 82.0 + }, + { + "part": "90357a013", + "qty": 2.0 + }, + { + "part": "91367a952", + "qty": 2.0 + }, + { + "part": "90264a435", + "qty": 2.0 + }, + { + "part": "92141a029", + "qty": 86.0 + }, + { + "part": "44665k137", + "qty": 2.0 + }, + { + "part": "50785k43", + "qty": 1.0 + }, + { + "part": "5485k31", + "qty": 1.0 + }, + { + "part": "50785k35", + "qty": 1.0 + }, + { + "part": "50675k435", + "qty": 2.0 + }, + { + "part": "50785k27", + "qty": 1.0 + }, + { + "part": "91102a750", + "qty": 53.0 + }, + { + "part": "3043t643", + "qty": 3.0 + }, + { + "part": "bkhd_rec_399_250c_2x170we", + "qty": 3.0 + }, + { + "part": "bkhd_ox_o238_8npt4npt", + "qty": 2.0 + }, + { + "part": "50675k163", + "qty": 1.0 + }, + { + "part": "7768k21", + "qty": 1.0 + }, + { + "part": "9396t31", + "qty": 2.0 + }, + { + "part": "50785k41", + "qty": 1.0 + }, + { + "part": "9685t3", + "qty": 1.0 + }, + { + "part": "qdc_750x125flg_281fb8npt", + "qty": 1.0 + }, + { + "part": "tank_36l_047v2x8x313c", + "qty": 1.0 + }, + { + "part": "pstn_2xo238_250t20", + "qty": 1.0 + }, + { + "part": "9452k226", + "qty": 4.0 + }, + { + "part": "91255a378", + "qty": 12.0 + }, + { + "part": "4468k858", + "qty": 1.0 + }, + { + "part": "50675k135", + "qty": 1.0 + }, + { + "part": "nzzl_45c20d_200e", + "qty": 1.0 + }, + { + "part": "thrt_45c20d_100t", + "qty": 1.0 + }, + { + "part": "cmbr_200di_250b_525l", + "qty": 1.0 + }, + { + "part": "injc_2x8npt_38npt_250t20_2xo230_basic", + "qty": 1.0 + }, + { + "part": "50675k164", + "qty": 1.0 + }, + { + "part": "50785k171", + "qty": 1.0 + }, + { + "part": "50785k91", + "qty": 1.0 + }, + { + "part": "9396k79", + "qty": 2.0 + }, + { + "part": "90213a101", + "qty": 1.0 + }, + { + "part": "94095k114", + "qty": 1.0 + }, + { + "part": "flng_st_25t_8x250c", + "qty": 2.0 + }, + { + "part": "brkt_90a_125t_2x250c", + "qty": 8.0 + }, + { + "part": "90281a102", + "qty": 12.0 + }, + { + "part": "90015a410", + "qty": 1.0 + }, + { + "part": "9368t14", + "qty": 1.0 + }, + { + "part": "8284n57", + "qty": 1.0 + }, + { + "part": "91306a279", + "qty": 8.0 + }, + { + "part": "spcr_400sd_250c0313c", + "qty": 4.0 + }, + { + "part": "91255a839", + "qty": 4.0 + }, + { + "part": "4468k031", + "qty": 1.0 + }, + { + "part": "4468k865", + "qty": 1.0 + }, + { + "part": "srv_3pin_1mxt", + "qty": 1.0 + }, + { + "part": "avby_hf_3994", + "qty": 2.0 + }, + { + "part": "avby_sb_4160", + "qty": 1.0 + }, + { + "part": "92580a328", + "qty": 2.0 + }, + { + "part": "avby_cr_4r", + "qty": 2.0 + }, + { + "part": "avby_sld_225x600_6xm4c", + "qty": 1.0 + }, + { + "part": "avby_mt_250r_m4c", + "qty": 1.0 + }, + { + "part": "avby_bh_2x9v250r_m4c", + "qty": 1.0 + }, + { + "part": "93505a211", + "qty": 4.0 + }, + { + "part": "91755a152_91755a200", + "qty": 8.0 + }, + { + "part": "egtmr_qrk", + "qty": 2.0 + }, + { + "part": "7712k511", + "qty": 2.0 + }, + { + "part": "20ga_rbp", + "qty": 1.0 + }, + { + "part": "9v_batt", + "qty": 2.0 + }, + { + "part": "nscn_sldr_4x250r8x832hnc", + "qty": 1.0 + }, + { + "part": "nscn_sc1_4x250r", + "qty": 1.0 + }, + { + "part": "nscn_sc2_4x250r", + "qty": 1.0 + }, + { + "part": "nscn_tip_4x250r250hnc", + "qty": 1.0 + }, + { + "part": "98831a028", + "qty": 2.0 + }, + { + "part": "95229a420", + "qty": 8.0 + }, + { + "part": "93135a013", + "qty": 4.0 + }, + { + "part": "3018t23", + "qty": 1.0 + }, + { + "part": "92994a029", + "qty": 1.0 + }, + { + "part": "rec_tnsc_3klb", + "qty": 2.0 + }, + { + "part": "20545t38", + "qty": 1.0 + }, + { + "part": "tmplt_af_lwr_416", + "qty": 1.0 + }, + { + "part": "tmplt_af_upr_416", + "qty": 1.0 + }, + { + "part": "plg_dril_af_upr_416", + "qty": 1.0 + }, + { + "part": "cnpy_cht_86in", + "qty": 1.0 + }, + { + "part": "crd_kvlr_250lb", + "qty": 1.0 + }, + { + "part": "rec_prt_16", + "qty": 2.0 + }, + { + "part": "rec_cht_drg_36", + "qty": 1.0 + }, + { + "part": "8947t26", + "qty": 4.0 + }, + { + "part": "97654a620", + "qty": 2.0 + }, + { + "part": "rlbtn_a_1515_250c", + "qty": 2.0 + }, + { + "part": "rlbtn_b_1515_250c", + "qty": 2.0 + }, + { + "part": "sabv_assy", + "qty": 2.0 + }, + { + "part": "fin_bracket_assy", + "qty": 4.0 + } + ] + }, + { + "key": "gse_assembly", + "name": "GSE Assembly", + "description": "Section VI build sequence for the ground support equipment: fill-system plumbing, toolbox enclosure, fluid and electrical integration, cables, and transmitter configuration (HCR-5100 GSE OPs 1-8).", + "type": "build", + "output": { + "part": "gse", + "qty": 1 + }, + "ops": [ + { + "title": "Assemble Fill System", + "instructions": "Assemble the N2O fill plumbing: CGA 326 supply-bottle adapter, hose adapters, servo-actuated fill valve with gauge tee and 0-2000 PSI supply gauge, through-wall bulkhead fittings, and the fill line bulkhead assembly. (HCR-5100 \u00a7VI OP 2.)", + "workcenter": "BENCH", + "step_kit": [], + "sub_steps": [ + { + "title": "Fit CGA 326 nut over adapter nipple", + "instructions": "Slide QTY 1 79215A672 CGA 326 nut over QTY 1 79215A673 CGA 326 adapter nipple so that the threads cover the rounded nub.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect flared adapter to CGA adapter nipple", + "instructions": "Apply 2-3 wraps of PTFE tape to the 79215A673 adapter nipple and QTY 1 50675K162 37-degree flared adapter fitting (source prints '2-2 wraps'; 2-3 used elsewhere). Connect the flared adapter fitting to the CGA adapter nipple using QTY 1 50785K92 female straight connector. Tighten all fittings to snug, 1-3 turns past hand tight.", + "caution": "N2O supply-side joint: verify PTFE tape application and tightness (1-3 turns past hand tight) on every fitting.", + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "confirm_ptfe_tape_applied_and_fittings", + "type": "text", + "label": "Confirm PTFE tape applied and fittings tightened 1-3 turns past hand tight", + "required": true + } + ] + } + }, + { + "title": "Assemble fill valve, tee, and through-wall fitting", + "instructions": "Apply 2-3 wraps of PTFE tape to the male threads of QTY 1 50675K162 flared adapter, QTY 1 50785K222 tee, QTY 1 50785K273 male straight connector, and QTY 1 9396T32 push-to-connect fitting. Assemble with QTY 1 50785K273 through-wall fitting and QTY 1 SABV-04FF valve as shown. Tighten all fittings to snug, 1-3 turns past hand tight. Ensure the 50785K222 tee is clocked as shown in the figure.", + "caution": "N2O plumbing joints: tee clocking and fitting tightness are critical for gauge readability and leak-free service.", + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "confirm_tee_clocking_matches_figure", + "type": "text", + "label": "Confirm tee clocking matches figure", + "required": true + }, + { + "name": "confirm_all_fittings_tightened_1_3", + "type": "text", + "label": "Confirm all fittings tightened 1-3 turns past hand tight", + "required": true + } + ] + } + }, + { + "title": "Install bulkhead spacer nut", + "instructions": "Thread the 97149A370 hex nut onto the 50785K273 through-wall fitting until approximately one thread remains between the nut and the fixed hex portion of the fitting, then loose-install the included brass jam nut and toothed washer.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install supply pressure gauge", + "instructions": "Install QTY 1 3846K93 gauge (0-2000 PSI, 2\" dial) into the side port of the 50785K222 tee with 2-3 wraps of PTFE tape on the threads. Tighten until snug, clocked so the gauge scale is horizontal for readability.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Assemble fill line bulkhead assembly", + "instructions": "Apply 2-3 wraps of PTFE tape to QTY 2 9396T31 push-to-connect fittings and install one into each end of QTY 1 50785K272 through-wall fitting to form the fill line bulkhead assembly. Tighten all fittings to snug, 1-3 turns past hand tight.", + "caution": "Fill line bulkhead carries N2O to the vehicle \u2014 verify tape and tightness on both fittings.", + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "confirm_both_9396t31_fittings_tightened", + "type": "text", + "label": "Confirm both 9396T31 fittings tightened 1-3 turns past hand tight", + "required": true + } + ] + } + } + ] + }, + { + "title": "Assemble Purge System (Optional)", + "instructions": "Assemble the optional CO2 purge valve branch (used for static testing only): a second SABV-04FF valve with through-wall fitting and check valves preventing cross-contamination between CO2 and N2O supplies. (HCR-5100 \u00a7VI OP 3.)", + "workcenter": "BENCH", + "step_kit": [], + "sub_steps": [ + { + "title": "Assemble purge valve plumbing", + "instructions": "Apply 2-3 wraps of PTFE tape to the male threads of QTY 2 9396T32 push-to-connect fittings, QTY 1 5485K22 male straight connector, and QTY 1 7768K26 check valve. Assemble with QTY 1 50785K273 through-wall connector and QTY 1 SABV-04FF valve as shown. Tighten all fittings to snug, 1-3 turns past hand tight.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "confirm_all_fittings_tightened_1_3", + "type": "text", + "label": "Confirm all fittings tightened 1-3 turns past hand tight", + "required": true + } + ] + } + }, + { + "title": "Install bulkhead spacer nut", + "instructions": "Thread the 97149A370 hex nut onto the 50785K273 through-wall fitting until approximately one thread remains between the nut and the fixed hex portion, then loose-install the included brass jam nut and toothed washer.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Add check valve to fill assembly", + "instructions": "If already installed, remove QTY 1 9396T32 push-to-connect fitting from the fill assembly. Install QTY 1 7768K26 check valve into the SABV-04FF outlet, then reinstall the 9396T32 into the check valve outlet. Apply 2-3 fresh wraps of PTFE tape to each fitting prior to installation. Note: this check valve is only required if a purge valve is also present; it prevents cross-contamination between the CO2 and N2O supply tanks if both valves are accidentally commanded open simultaneously with the tanks at slightly different pressures. If no purge valve is included, no check valves are needed on the fill system.", + "caution": "Check valve orientation protects against CO2/N2O supply cross-contamination \u2014 required whenever a purge valve is present.", + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Prepare Toolbox Enclosure", + "instructions": "Cut and drill all penetrations in the 91111 21-inch Voyager steel toolbox: gauge window, valve bulkhead holes, outlet/switch holes, and back-face electronics/eyebolt holes. Hole positions are given in figures. (HCR-5100 \u00a7VI OP 4.)", + "workcenter": "SHOP", + "step_kit": [], + "sub_steps": [ + { + "title": "Cut gauge hole in front face", + "instructions": "Using a hole saw, cut a 2 1/8-inch-diameter hole where shown on the front face of the toolbox (for the pressure gauge).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Drill right-side holes", + "instructions": "Drill the holes as shown in the side of the toolbox that is on the right when facing the front (the latch side). Note: if no purge valve is included, only the left-most of the two 3/4-inch holes is needed. (A 1/2\" hole for the power switch is also on this side per OP 5.)", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Drill left-side holes and slot", + "instructions": "Drill the holes as shown in the left side of the toolbox. Use a hole saw for the 1.5-inch holes, and cut an approximately 1/4-inch side slot between them using a hacksaw. Note: the imperfect alignment of the 1.5-inch holes with the NEMA electrical outlets is intentional, to avoid overlap between the holes which can cause the hole saw blade to bind.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Drill back-face holes", + "instructions": "Drill the holes as shown in the back side of the toolbox (electronics bracket, antenna cover, and 1/4\" eyebolt clearance holes; dimensions given in figure only).", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Fluid System Integration", + "instructions": "Install the fill valve assembly, fill line bulkhead, and (optionally) the purge valve into the toolbox, and connect them with 1/4\" OD nylon tubing. (HCR-5100 \u00a7VI OP 5.)", + "workcenter": "BENCH", + "step_kit": [ + { + "part": "sabv_assy", + "qty": 1 + } + ], + "sub_steps": [ + { + "title": "Install fill valve assembly through toolbox wall", + "instructions": "Remove the brass nut and toothed washer from the 50785K273 through-wall fitting on the fill valve assembly. Pass the fitting through the 0.75\" hole in the toolbox where shown, then re-install the toothed washer and brass nut. The 97149A370 hex nut should contact the inside of the toolbox. Ensure the gauge is aligned with the hole in the front face, then tighten the brass nut against the 97149A370 hex nut to clamp the assembly in place. Note: the configuration shown includes the check valve, which is only required if a purge valve is also included.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install fill line bulkhead assembly", + "instructions": "Remove the brass nut and toothed washer from the 50785K273 through-wall fitting on the fill line bulkhead assembly and install into the 0.67\" hole on the left side of the toolbox. Re-install the nut and washer onto the threaded portion projecting into the toolbox and tighten to clamp the assembly in place. (Source names 50785K273 here; the fill line bulkhead was built on the 1/8 NPT 50785K272 fitting in OP 1.)", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect fill valve to bulkhead with tubing", + "instructions": "Cut a section of 9685T3 tubing approximately 13.5 inches long. Insert one end into the 9396T32 push-to-connect fitting on the fill valve assembly (source prints '9696T32') and the other end into the fill line bulkhead assembly. Some slack in the line aids installation; it will not be perfectly straight as depicted in the CAD model.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Purge branch gate", + "instructions": "The following steps are required ONLY if a purge valve is to be included in the system. If only a fill valve is installed, proceed to the next operation.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install purge valve (optional)", + "instructions": "Install the purge valve into the second 0.75\" hole in the right face of the toolbox. The 97149A370 hex nut should contact the inner face of the toolbox, with the brass nut and toothed washer on the outside, as on the fill valve assembly. Ensure the valve is oriented vertically with the servo on top, then tighten the two nuts against one another to clamp the assembly in place.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Splice tee into fill line (optional)", + "instructions": "Cut the 9685T3 fill tube approximately 4.5 inches from the fill valve assembly and insert QTY 1 9396T61 push-to-connect tee. If needed, remove a short section of tube to reduce excess slack.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect purge valve to tee (optional)", + "instructions": "Cut a section of 9685T3 tubing approximately 8 inches long. Insert one end into the 9396T32 fitting on the purge valve assembly and the other end into the branch of the 9396T61 tee.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Electrical System Integration", + "instructions": "Mount and wire the control electronics inside the toolbox: FS-iA10B receiver, PWM relay module, voltage regulator, power switch, dual NEMA outlet (IGN/SRV), battery eyebolt restraints, and receiver/servo channel connections per the schematic. (HCR-5100 \u00a7VI OP 6.)", + "workcenter": "ELEC", + "step_kit": [], + "sub_steps": [ + { + "title": "Mount receiver, relay, and regulator", + "instructions": "Mount the FS-iA10B receiver and PWM relay module onto the inside back face of the toolbox using QTY 2 BRKT-EGSE-2X8C brackets. Secure the brackets and DROK-12V-5V voltage regulator using QTY 6 8-32 x 1/2\" screws (9027A194) with 98689A113 flat washers, 92148A160 lock washers, and 90480A009 hex nuts. Note: M4 flat and lock washers are used with #8 screws to reduce unique parts; the relay module may differ slightly in appearance from the CAD model.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install antenna cover", + "instructions": "Pass the FS-iA10B antennas through the indicated hole and lay them into the slots in the CVR-ANT-2X8C antenna cover. Secure the cover to the outer back face using QTY 2 8-32 screws with flat washers, lock washers, and hex nuts.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install battery eyebolts", + "instructions": "Install QTY 2 9489T519 eyebolts into the 1/4\" clearance holes in the back face, projecting inward with the eyes oriented vertically. Secure using the included hex nuts and QTY 2 92141A029 flat washers, QTY 2 91102A750 lock washers, and QTY 2 95505A601 nuts. These eyebolts secure the 12V battery in a later step.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install power switch with guard", + "instructions": "Install QTY 1 GSW-17-SPST toggle switch with QTY 1 TGL-GRD-12MM guard into the 1/2\" hole on the right side of the toolbox. Orient the switch so the toggle is angled downward in the off position, when covered by the toggle guard.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Isolate outlet receptacles", + "instructions": "Using needle-nose pliers, break off the small metal tab on the side of the 7159K91 outlet that connects the two receptacles, electrically isolating the two plugs from one another.", + "caution": "The IGN and SRV receptacles must be electrically isolated \u2014 a shared tab would cross-connect the igniter and servo circuits.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Mount electrical outlet", + "instructions": "Install QTY 1 7159K91 outlet onto the inner left face of the toolbox using QTY 4 8-32 screws with flat washers, lock washers, and hex nuts.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install wall plate and label outlets", + "instructions": "Install the 7526K53 wall plate over the outlet using the included mounting screw. Label the outlets 'IGN' and 'SRV' for the igniter and servo outputs. Alternatively, 3D print the CAD part model with engraved text; a 6-32 x 1/2\" screw is required to mount the printed plate.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Wire SRV receptacle (Vehicle Valves cable)", + "instructions": "Cut the female (larger) plug off QTY 1 SRV-3PIN-1M servo extension cable and strip approximately 0.5\" of each wire. Connect to the screw terminals of the receptacle closest to the toolbox front (SRV): white wire to the terminal for the longer of the two slots; red wire to the terminal for the shorter slot; black wire to the ground-pin terminal (grounds of the two plugs must be isolated by the broken-off tab). This is the Vehicle Valves cable that connects to the rocket.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect servo leads to receiver channels", + "instructions": "Connect the servo leads to the FS-iA10B receiver per the schematic: Fill Valve to Ch5, Vehicle Valves servo extension to Ch6, Purge Valve to Ch8.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "record_channel_assignments_fill_ch5", + "type": "text", + "label": "Record channel assignments: Fill=Ch5, Vehicle Valves=Ch6, Purge=Ch8", + "required": true + } + ] + } + }, + { + "title": "Connect relay module to Ch3", + "instructions": "Connect the 3-pin cable from the PWM-controlled relay module to Ch3 (throttle channel) as shown in the schematic.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Wire power switch, regulator, relay, and IGN outlet", + "instructions": "Using 20GA stranded wire, connect the power switch, voltage regulator, and relay module per the schematic. Connect the 12V positive wire from the relay to the screw terminal for the longer slot, and battery ground to the terminal for the shorter slot, on the second electrical outlet (IGN). This supplies power to the igniter.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Wire receiver power from voltage regulator", + "instructions": "Cut the male (smaller) plug off QTY 1 SRV-3PIN-1M servo extension cable and strip approximately 0.5\" of the red and black wires (white wire unused). Splice the red wire to the positive 5V output (yellow wire) of the DROK-12V-5V regulator and the black wire to the negative (black) 5V output. Splices should be tightly twisted and insulated with electrical tape or heat shrink; soldering recommended but not required (crimp ring terminals also acceptable). Connect the 3-pin plug to Ch1/PPM on the FS-iA10B \u2014 this powers the receiver and servos.", + "caution": "DO NOT connect to the battery until all wiring is complete.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Secure battery", + "instructions": "Place the battery between the two eyebolts and secure with 2-3 nylon zip ties looped through the eyes and across the front of the battery.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect battery", + "instructions": "With the power switch in the OFF position, connect the wires to the battery terminals as shown in the schematic.", + "caution": "Power switch must be OFF before connecting battery terminals.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Dress wiring", + "instructions": "Bundle wires together and onto fill line tubing for tidiness and strain relief as needed, using zip ties and/or electrical tape.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Fabricate Cables and Fill Line", + "instructions": "Fabricate the long-run servo cable and ignition cable from 16/3 extension cords, and the ~6 ft fill line with quick-disconnect for connecting the GSE to the rocket. (HCR-5100 \u00a7VI OP 7.)", + "workcenter": "ELEC", + "step_kit": [], + "sub_steps": [ + { + "title": "Prepare extension cords", + "instructions": "Cut the female end off QTY 2 16-3-10FTXT extension cords and strip approximately 1 inch of each wire.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Prepare servo extension", + "instructions": "Cut the male end off QTY 1 SRV-3PIN-1MXT servo extension cable and strip approximately 1 inch of each wire.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Splice servo cable", + "instructions": "Splice the servo extension wires onto one extension cord: red (servo) to black (cord); white (servo) to white (cord); black (servo) to green (cord). Splices should be tightly twisted and insulated with electrical tape or heat shrink; soldering recommended but not required.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "verify_splice_color_mapping_red_black", + "type": "text", + "label": "Verify splice color mapping: red-black, white-white, black-green", + "required": true + } + ] + } + }, + { + "title": "Build ignition cable", + "instructions": "Crimp QTY 2 insulated alligator clips onto the black and white wires of the second extension cord (one per wire) for connecting to the igniter e-match wires. Clips may be omitted in favor of splicing stripped ends onto the e-match during pre-launch setup. Cut the unused green wire flush with the outer insulation jacket.", + "caution": "CAUTION: NEVER plug the ignition or servo cables into a live 120V wall outlet. Doing so creates EXTREME DANGER of electrocution and/or fire. Ensure the servo wire is ONLY plugged into the SRV outlet on the GSE box; plugging the servo lead into the IGN outlet may damage the servos.", + "requires_signoff": true, + "schema": null + }, + { + "title": "Cut fill line", + "instructions": "Cut a section of 9685T3 nylon tubing about 6 feet in length. This serves as the fill line from the GSE to the rocket.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install PTC fitting into QD", + "instructions": "Apply 2-3 wraps of PTFE tape to the male threads of QTY 1 9396T31 push-to-connect fitting and install into the port of QTY 1 QDC-750x125FLG-270MB-O007 male QD fitting.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Seat fill line in PTC fitting", + "instructions": "Firmly push one end of the 9685T3 tube into the push-to-connect fitting until it is fully seated.", + "caution": "Fill line integrity: tube must be fully seated in the push-to-connect fitting \u2014 this line carries N2O to the vehicle.", + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "confirm_tube_fully_seated_in_ptc_fitting", + "type": "text", + "label": "Confirm tube fully seated in PTC fitting", + "required": true + } + ] + } + }, + { + "title": "Install QD O-ring", + "instructions": "Install QTY 1 9452K15 -007 Buna-N O-ring onto the male barb of the QD fitting. The seal may be lightly greased to aid installation. The QD O-ring generally lasts many uses and must only be replaced if visibly damaged.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "inspect_qd_o_ring_condition_replace_if", + "type": "text", + "label": "Inspect QD O-ring condition; replace if visibly damaged", + "required": true + } + ] + } + } + ] + }, + { + "title": "Transmitter Setup & Firmware Configuration", + "instructions": "Install switch covers on the FS-i6X transmitter, enable all 10 channels, assign channels to switches, set the igniter throttle-stick lockout, adjust servo endpoints for valve open/closed positions, verify channel mapping, and configure and verify receiver failsafes. (HCR-5100 \u00a7VI OP 8.)", + "workcenter": "ELEC", + "step_kit": [], + "sub_steps": [ + { + "title": "Install and label toggle switch covers", + "instructions": "Drill a 1/8\" hole in the lower right corner of the metal base of QTY 4 TGL-GRD-12MM switch covers. Place covers over the four toggle switches and mark hole locations on the transmitter casing. Using a #37 (.104\") drill bit, drill each marked location adjacent to the switches \u2014 remove the back half of the transmitter casing while drilling to verify no damage to switches or wiring. Install covers with QTY 4 92295A100 thread-forming screws (adhesive may supplement but not replace screws; the right-most cover corner may need slight bending with pliers). Label/engrave: Switch A (left-most) PRG (if purge included), Switch B FILL, Switch C IGN, Switch D (right-most) SRV. Only 3 covers required if purge is not included.", + "caution": "Take care not to damage the transmitter switches or internal wiring while drilling.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Enable all 10 channels", + "instructions": "Turn the transmitter on (all switches UP, left/throttle stick fully down \u2014 a boot-up safety feature). Hold OK -> MENU -> System Setup -> OK. Navigate DOWN to Aux Switches -> OK. Press OK until the arrow is next to Ch: 6, press UP until Ch: 10. Hold CANCEL to save (higher-pitched beep confirms), then OK to return to the main MENU.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Assign channels to switches", + "instructions": "DOWN -> Functions Setup -> OK; DOWN to Aux. Channels -> OK. Set Channel 5 Source = SwB -> OK. Set Channel 6 Source = SwD -> OK. Channel 7: OK to skip. Set Channel 8 Source = SwA. Hold CANCEL to save (higher-pitched beep confirms).", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "record_switch_assignments_ch5_swb_fill", + "type": "text", + "label": "Record switch assignments: Ch5=SwB (FILL), Ch6=SwD (SRV/vehicle), Ch8=SwA (PRG)", + "required": true + } + ] + } + }, + { + "title": "Assign igniter channel and set left-stick lockout", + "instructions": "The igniter relay is on the throttle channel (Ch3) so the left stick must be up to energize the igniter circuit \u2014 a safety lockout secondary to the switch cover. (Assigning the relay to an aux channel on Switch C instead is not recommended: it increases accidental-activation risk.) In FUNCTIONS menu: DOWN to Assign Switches -> OK; Fly mode None -> OK to skip; Idle mode None -> OK to skip; Thro. Hold -> DOWN until SwC; hold CANCEL to save. If the relay module has a minimum PWM value (e.g. dual-channel modules), set Throttle Hold to On and adjust the hold value so both relay channels are off with the left stick fully down; hold CANCEL to save.", + "caution": "Igniter energization requires BOTH Switch C and the left stick fully up \u2014 verify the lockout behaves as designed.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Adjust servo endpoints", + "instructions": "Do this before initial SABV assembly to ensure correct servo horn orientation. With transmitter ON and all switches DOWN before powering the GSE, connect ONLY the FILL valve servo to Ch5 (disconnect all other servos; ensure no pressure source is connected to the GSE fill or purge supply fittings). In FUNCTIONS -> End Points, select Ch5 right-side value and adjust until the servo horn/valve handle is exactly perpendicular to the servo/valve body (valve CLOSED). Flip Switch B UP to actuate FILL OPEN; adjust until horn/handle is exactly parallel to the body (valve OPEN). Hold CANCEL to save. Repeat for vehicle valves on Ch6 (connect individually for initial setup, or both via the servo Y-harness with airframe removed for fine-tuning; a <5 degree discrepancy between fuel and ox valves is acceptable as long as the ball fully contacts the seat without the port overlapping the seat's edge), with Switch D actuating OPEN. If a purge valve is used, connect it to Ch8 (Switch A) and repeat. Tip: if the servo stalls against the bracket, listen to the servo whine and adjust in the direction that decreases it; avoid stalling the servo for long periods (overheating/damage). If positions can't be reached within the endpoint range, reinstall the servo horn on the spur gear in a different orientation.", + "caution": "Ensure no pressure source is connected to the GSE fill or purge supply fittings during endpoint adjustment. Avoid prolonged servo stall.", + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "fill_valve_ch5_endpoint_values_closed", + "type": "text", + "label": "Fill valve Ch5 endpoint values (CLOSED perpendicular / OPEN parallel)", + "required": true + }, + { + "name": "vehicle_valves_ch6_endpoint_values_fuel", + "type": "text", + "label": "Vehicle valves Ch6 endpoint values; fuel-vs-ox discrepancy <5 degrees", + "required": true + }, + { + "name": "purge_valve_ch8_endpoint_values_if", + "type": "text", + "label": "Purge valve Ch8 endpoint values (if included)", + "required": true + } + ] + } + }, + { + "title": "Verify channel mapping", + "instructions": "Verify: FILL valve \u2014 Switch B UP = OPEN, DOWN = CLOSED. VEHICLE valves \u2014 Switch D UP = OPEN, DOWN = CLOSED. PURGE valve (if included) \u2014 Switch A UP = OPEN, DOWN = CLOSED (source lists 'Switch B DOWN' for purge closed, apparent typo for Switch A). IGNITER circuit (verify via multimeter or spark checks) \u2014 Switch C and left stick both fully DOWN: DE-ENERGIZED; both fully UP: ENERGIZED; Switch C UP with stick DOWN: DE-ENERGIZED; Switch C DOWN with stick UP: DE-ENERGIZED. Note: Switch C is the only 3-position switch on the FS-i6X; the middle position is effectively ignored and it should only be set fully up or fully down.", + "caution": "Igniter checks must be performed with a multimeter or spark check, not a live igniter.", + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "fill_valve_response_verified_swb", + "type": "text", + "label": "FILL valve response verified (SwB)", + "required": true + }, + { + "name": "vehicle_valves_response_verified_swd", + "type": "text", + "label": "VEHICLE valves response verified (SwD)", + "required": true + }, + { + "name": "purge_valve_response_verified_swa_if", + "type": "text", + "label": "PURGE valve response verified (SwA, if included)", + "required": true + }, + { + "name": "igniter_circuit_truth_table_verified", + "type": "text", + "label": "IGNITER circuit truth table verified (SwC + left stick, all four combinations)", + "required": true + } + ] + } + }, + { + "title": "Configure and verify receiver failsafes", + "instructions": "Failsafes must put the system in a safe state on loss of link: fill and purge valves CLOSED, vehicle valves OPEN (no pressure remains in the rocket), igniter DE-ENERGIZED. From home: Hold OK -> System Setup -> OK -> DOWN to RX Setup -> DOWN to Failsafe -> OK. Igniter: select Channel 3 -> OK -> left stick and Switch C both fully down -> UP to change OFF to ON -> OK. Fill: Channel 5 -> OK -> Switch B DOWN (FILL CLOSED) -> ON -> OK. Vehicle valves: Channel 6 -> OK -> Switch D UP (VEHICLE OPEN) -> ON -> OK. Purge (if included): Channel 8 -> OK -> Switch A DOWN (PURGE CLOSED) -> ON -> OK. Hold CANCEL to save. IMPORTANT: failsafe settings are saved only when holding CANCEL to exit the Failsafe menu \u2014 NOT when pressing OK from an individual channel screen. ALWAYS verify failsafe settings are set and saved correctly. To verify: with all valve servos and the igniter relay connected, no pressure supply connected, and GSE powered on, set Switch A UP (purge OPEN), Switch B UP (fill OPEN), Switch C and left stick fully UP (igniter ENERGIZED \u2014 ensure igniter leads are not connected to anything and are insulated from each other), Switch D DOWN (vehicle valves CLOSED). Turn the transmitter POWER OFF and verify the system enters: PURGE CLOSED, FILL CLOSED, IGNITER DE-ENERGIZED (multimeter or spark check), VEHICLE valves OPEN.", + "caution": "Loss-of-link failsafe is the primary means of safing the vehicle during fill. Settings are NOT saved unless CANCEL is held at the Failsafe menu level. During the verification test, igniter leads must be disconnected from everything and insulated from each other.", + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "failsafe_on_confirmed_for_ch3_igniter", + "type": "text", + "label": "Failsafe ON confirmed for Ch3 (igniter de-energized), Ch5 (fill closed), Ch6 (vehicle open), Ch8 (purge closed, if included)", + "required": true + }, + { + "name": "transmitter_off_failsafe_test_result", + "type": "text", + "label": "Transmitter-off failsafe test result: purge CLOSED, fill CLOSED, igniter DE-ENERGIZED, vehicle valves OPEN", + "required": true + } + ] + } + } + ] + } + ], + "kit": [ + { + "part": "sabv_assy", + "qty": 1 + } + ] + }, + { + "key": "launch_ops", + "name": "Launch Operations", + "description": "Section VII launch-day sequence: vehicle preparation, GSE setup, launchpad operations, launch procedure, launchpad shutdown, and recovery (HCR-5100 \u00a77.1-7.6). Hold points require sign-off.", + "type": "op", + "output": null, + "ops": [ + { + "title": "Vehicle Preparation", + "instructions": "Preparation of the vehicle for static testing or launch. Recovery system packing is only required for launch. Assumes the rocket has been fully assembled per Section 5; contains only the steps repeated each time the rocket is recycled. Subsections: 7.1.1 Fuel Loading, 7.1.2 Igniter Installation, 7.1.3 Recovery System Packing. (HCR-5100 \u00a77.1.)", + "workcenter": "PAD", + "step_kit": [ + { + "part": "e85", + "qty": 2 + }, + { + "part": "a3_4t", + "qty": 1 + }, + { + "part": "1834", + "qty": 4 + }, + { + "part": "1318", + "qty": 1 + }, + { + "part": "52555t644", + "qty": 1 + }, + { + "part": "7619a11", + "qty": 1 + }, + { + "part": "9452k226", + "qty": 1 + } + ], + "sub_steps": [ + { + "title": "Fuel loading \u2014 gather tools and materials", + "instructions": "Gather: 5/8\" and 9/16\" wrenches, 3/16\" hex key/driver, optional torque-clutch drill, sturdy PVC pipe or dowel marked 12.25\" from one end, safety glasses, nitrile gloves, O-ring grease, 1X -238 Buna-N O-ring (PN 9452K226), shop towels, approx. 1/2 gallon fuel (see Section 2.6). If piston is to be removed (required every 3 firings or sooner): 1/4-20 bolt or threaded rod, vise-grip/large pliers, 2X additional -238 Buna-N O-rings.", + "caution": "Handling of fuel and O-ring grease: all personnel wear safety glasses and nitrile gloves. Perform outdoors or in a well-ventilated area with disposable shop towels on hand for fuel spills.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Disconnect fuel line", + "instructions": "Disconnect the fuel line from the flared fitting on the fuel valve outlet using the 5/8\" and 9/16\" wrenches.", + "caution": "Residual fuel may be present \u2014 be prepared for a small amount to drip from the fuel valve outlet and/or injector.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Ensure fuel valve OPEN", + "instructions": "Ensure the fuel valve is OPEN. If closed, open using the GSE or by slowly and gently moving the valve handle/servo horn by hand (servo must be disconnected from power to move manually). If the valve is closed, the forward tank bulkhead cannot be removed, as it will pull a vacuum.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Disconnect servo wire and interface ring fasteners", + "instructions": "Disconnect the fuel valve servo wire from the vehicle Y-harness at the 3-pin connector closest to the servo. Remove the 8X 5/16\" fasteners from the forward tank interface ring (3/16\" hex); set aside for reinstallation (magnetic dish recommended).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Remove fuel bulkhead assembly", + "instructions": "Remove the entire fuel bulkhead assembly by pulling on the U-bolt in the recovery bulkhead (lower airframe tube may remain connected to the interface ring). When static testing without the recovery bulkhead, pull on 1/4-20 threaded rods/coupling nuts instead. Do not pull on the valve assembly \u2014 it may damage the valve assembly and/or fuel tank outlet port/fitting.", + "caution": "Residual fuel will likely be present in the tank. Remove the fuel bulkhead assembly with the rocket vertical or tilted upwards to prevent spillage.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Piston seal service (every 3 firings)", + "instructions": "Only if the piston must be removed prior to the next firing (servicing the piston seals is required every three firings): insert a 1/4-20 bolt/threaded rod into the tapped blind hole in the piston center until it bottoms out (hand tight only), pull the piston out of the forward end (vise-grips/pliers may improve grip), remove and discard the 2X O-rings, clean the grooves of particulate or darkened grease, grease and install 2X new -238 O-rings, then re-install the piston with the 1/4-20 blind hole facing the open forward end using even pressure to push the O-rings past the tank edge and bolt holes. Verify the piston extraction fastener has been removed before proceeding.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "piston_seal_service_performed_and", + "type": "checkbox", + "label": "Piston seal service performed and firing count since last service", + "required": true + } + ] + } + }, + { + "title": "Set piston depth", + "instructions": "Push the piston down into the tank until the lower surface closest to the outer edge is 12.25 +0/-.25\" from the forward end of the tank tube, using the marked PVC pipe or dowel.", + "caution": "Do not apply more than 100lbf to the piston if the vehicle is resting on the plywood fins; if more force is required, place a wooden or concrete block under the nozzle. No more than 150lbf should be required to install the piston to its final depth. DO NOT use a hammer or mallet to push the piston down \u2014 it may cause the piston to bind against the tank wall, preventing fuel flow when the valve is opened.", + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "piston_depth_from_forward_end_of_tank", + "type": "text", + "label": "Piston depth from forward end of tank tube (target 12.25 +0/-.25 in)", + "required": true + } + ] + } + }, + { + "title": "Verify static vent unobstructed", + "instructions": "Verify that the nitrous tank static vent is unobstructed by poking a small piece of wire (such as from a spent e-match) at least 1/2\" into the vent orifice. If obstructed, pull the piston upwards using a long 1/4-20 threaded rod until the piston no longer blocks the vent. Blockage of the vent will prevent oxidizer loading.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "static_vent_verified_clear", + "type": "checkbox", + "label": "Static vent verified clear", + "required": true + } + ] + } + }, + { + "title": "Inspect fuel bulkhead O-ring", + "instructions": "Inspect the fuel bulkhead O-ring for damage or swelling. If replacement is necessary, remove and discard, then grease and install 1X new -238 O-ring into the bulkhead groove.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "fuel_bulkhead_o_ring_replaced", + "type": "checkbox", + "label": "Fuel bulkhead O-ring replaced", + "required": true + } + ] + } + }, + { + "title": "Load fuel", + "instructions": "With the rocket vertical, pour approximately 1/2 gallon of fuel into the tank on top of the piston. Fuel may be pre-measured, or simply filled until the level reaches 1.25\" from the end of the tank tube. Excess fuel will be pushed out through the fuel valve when the bulkhead is reinstalled. The nominal volume of the fuel tank is 120 cubic inches (.52 gal); a 1/2\" variation in fuel tank length due to fill level and/or piston position will result in <5% variation in total impulse.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "fuel_type_and_quantity_loaded", + "type": "text", + "label": "Fuel type and quantity loaded", + "required": true + }, + { + "name": "fuel_level_from_tank_tube_end_target_1", + "type": "text", + "label": "Fuel level from tank tube end (target 1.25 in)", + "required": true + } + ] + } + }, + { + "title": "Reinstall fuel bulkhead assembly", + "instructions": "Insert the fuel bulkhead assembly into the forward end of the tank, ensuring proper orientation of the fuel valve outlet fitting and alignment of any clocking marks. Use even pressure to push the O-ring past the tank edge and bolt holes; push the interface ring down until it seats against the edge of the tank tube.", + "caution": "Any excess fuel will be pushed out through the fuel valve outlet \u2014 use a shop towel or container against the outlet fitting to catch fuel spray.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Close fuel valve and reassemble", + "instructions": "Close the fuel valve (GSE, or by hand with servo unpowered). The fuel tank is now fully sealed and the rocket may be returned horizontal if desired. Reinstall the 8X 5/16\" fasteners into the forward tank retaining ring (leave loose until all 8 installed, then tighten to snug). Reconnect the fuel line hose to the flared fittings on the fuel valve outlet and tighten to snug. Reconnect the fuel valve servo wire to the 3-pin extension and secure the connector with 1-2 wraps of electrical or masking tape. Fuel loading is now complete.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Igniter installation \u2014 gather items", + "instructions": "Gather: 9/16\" and 15/16\" wrenches, multimeter with continuity check, 3/8\" drill bit, optional battery drill, long (>6\") hemostats, 1X e-match (MJG FireWire or equivalent) at least 16\" long, 1X Estes A3-4T 13mm black powder motor, grease, shop towels.", + "caution": "Pyrotechnic materials handling: safety glasses required for all personnel in the vicinity; face shields recommended for those directly handling pyrotechnics. Perform outdoors, away from flammable materials and ignition sources.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Open igniter cartridge and inspect flow path", + "instructions": "Disconnect the igniter cartridge assembly from the injector at the coupler fitting between the two pipe nipples (do not disconnect the lower pipe nipple from the injector port \u2014 it wears the aluminum threads). Remove the forward cap. If previously fired, remove the spent igniter motor (needle nose pliers, curved tweezers, or hooked pick). Inspect the igniter cartridge and injector for blockage of the igniter flow path; clear with stiff wire or a 1/8\" tube brush. Blockages of less than 50% of the igniter inlet orifice area are acceptable so long as they do not prevent insertion of the e-match wires.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Prepare igniter motor", + "instructions": "Remove the clay nozzle from the Estes A3-4T using a 3/8\" drill bit (by hand or drill at low speed) until the dark gray propellant grain is exposed across the full bit diameter (small amount of clay on the outer edges acceptable). Remove the ejection charge by drilling out the clay cap at the forward end until the loose black powder is exposed; scrape out or leave residual powder (removal recommended but not required \u2014 no detrimental impact demonstrated from leaving it).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Continuity-check and shunt e-match", + "instructions": "Test a new e-match for continuity with a multimeter; if no continuity, do not use it \u2014 replace with one that reads continuity. Shunt the e-match by twisting the exposed wire leads together to prevent accidental ignition from static discharge or other current sources. Tightly twist the first 3-5\" of the leads to aid installation; the twisted wires may be greased to slide through the igniter flow path.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "igniter_e_match_continuity_result", + "type": "text", + "label": "Igniter e-match continuity result", + "required": true + } + ] + } + }, + { + "title": "Install e-match and cartridge", + "instructions": "Insert the wire-lead end of the e-match into the igniter fittings on the injector until the wires can be seen projecting into the chamber. Insert the e-match head into the pipe nipple on the cartridge assembly until the head and 3-4\" of wire protrude from the forward end. Thread the cartridge pipe nipple into the coupler fitting and tighten to snug (PTFE thread tape not required for brass igniter fittings; any leakage is minimal and non-detrimental). Fold the e-match wire 3-4 times approximately 1/2\" below the head to prevent it being pulled out during pre-launch handling while still allowing ejection at ignition.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Seat igniter motor and close cartridge", + "instructions": "Place the cored end of the prepared A3-4T against the e-match head and gently push it into the cartridge, keeping the e-match head in contact with the propellant grain. Using long hemostats through the nozzle throat, gently pull the e-match leads until they extend several inches out of the nozzle; leave the stripped ends twisted together (shunted) to prevent accidental ignition (bare ends may be partially untwisted to ease separation at the pad). Place a small wad of shop towel into the forward end of the cartridge to fill the empty volume and maintain igniter-motor/e-match contact, then reinstall the cap and tighten to snug. Igniter installation is now complete.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Recovery packing \u2014 gather items and prepare ejection charges", + "instructions": "Gather: safety glasses, nitrile gloves, electrical tape, masking tape, 4X e-matches (MJG FireWire or equivalent, at least 16\" long), FFFG (3F) Pyrodex powder, gram scale or 1/2 tsp measuring spoon, multimeter, 4X 2-56 nylon screws (PN 94735A707). Check continuity on all 4X e-matches (discard and replace any that fail), then shunt the bare leads. Cut 4X fingers off a nitrile glove; measure 8 grams or 1.5 teaspoons of 3F Pyrodex into each. Insert an e-match head fully submerged in the powder, twist the open end around the wires, and wrap each charge tightly in 4-5 layers of electrical tape, stretching to compress: charges should be hard, should not deform when pinched, and have no excess volume for powder to move around.", + "caution": "Pyrotechnic materials handling: safety glasses required for all personnel in the vicinity; face shields recommended for those directly handling. Gloves may be used to prevent skin contact with Pyrodex. Ejection charges are conservatively sized; ground testing is strongly recommended to verify/refine charge sizing, especially with airframe modifications or alternative materials such as 4F black powder.", + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "continuity_results_for_4x_ejection_e", + "type": "text", + "label": "Continuity results for 4X ejection e-matches", + "required": true + }, + { + "name": "charge_mass_per_charge_8_g_1_5_tsp_3f", + "type": "text", + "label": "Charge mass per charge (8 g / 1.5 tsp 3F Pyrodex)", + "required": true + } + ] + } + }, + { + "title": "Verify altimeter batteries and connect charges", + "instructions": "Before connecting any charges, verify acceptable battery voltage for both altimeter batteries (method per deployment controller manufacturer); replace or recharge if needed. Connect the 4X completed ejection charges to the deployment outputs \u2014 one each to Altimeter A Main, Altimeter A Drogue, Altimeter B Main, Altimeter B Drogue \u2014 using Western Union splices, wrapping all splices thoroughly with electrical tape (see avbay wiring diagram, Fig. 7.5).", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "altimeter_a_and_b_battery_voltages", + "type": "text", + "label": "Altimeter A and B battery voltages", + "required": true + } + ] + } + }, + { + "title": "Pack main parachute", + "instructions": "At least two people recommended. Verify the main recovery harness is securely connected to the nose cone and avbay assemblies before proceeding. Gather the main canopy from its center into a flat triangle with shroud line attachment points roughly even; fold into a strip approximately 8-10\" wide; Z-fold so each segment is approximately 1/3 of the canopy radius; tightly roll from the end opposite the shroud lines into a bundle approximately 3.5\" in diameter (do not wrap shroud lines around the roll). Place the rolled canopy on the Nomex protector with shroud lines zig-zagged alongside, and wrap burrito-style so the canopy is fully covered.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Load main charges, parachute, and shock cord", + "instructions": "Place the two main deployment charges into the upper airframe tube several inches from the end, then insert the Nomex-wrapped main bundle behind the charges and push in as far as possible with moderate force, without straining the charge wire splices. It is critical that the main parachute bundle is positioned between the ejection charges and the open end of the tube where the airframe will separate in flight \u2014 this lets the charges and airframe act as a \"parachute cannon\". Bundle the main shock cord into 4-5 sections of about 6 feet each, each wrapped with 1-2 wraps of masking tape at a single point (energy expended breaking the tape dissipates deployment shock; other tape types must NOT be used; rubber bands acceptable). Pack the bundles behind the parachute, then insert the avbay coupler \u2014 a moderately snug fit with no wobble; shim with masking or aluminum tape (not electrical tape).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Install shear pins", + "instructions": "Align the 4X shear pin holes in the upper airframe tube with the slots along the lower edge of the forward avbay bulkhead, and insert a 2-56 nylon screw into each hole (press fit; screwdriver may help; a small tape patch over the heads may retain loose pins). Shear pins should be included in ground testing of main parachute ejection to verify they break cleanly without bending or tearing the airframe holes, which can cause coupler binding and failure to deploy. Main parachute packing is now complete.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Pack drogue parachute and mate vehicle", + "instructions": "Verify the drogue recovery harness is securely attached to the recovery bulkhead on the propulsion section and the avbay assembly. Fold the drogue canopy similarly to the main (packing method less critical; shroud lines may be wrapped around the folded canopy). Wrap fully in the Nomex drogue protector, burrito-style. Place the 2X drogue ejection charges into the lower airframe tube several inches from the end, insert the wrapped drogue bundle behind the charges (the drogue fits loosely, so the parachute-cannon effect is less critical \u2014 section momentum after separation is generally sufficient to extract it). Bundle the drogue shock cord into 4-5 sections of about 6 feet each with a single masking-tape wrap per bundle, pack behind the parachute, and insert the avbay coupler (snug, shimmed with masking/aluminum tape as needed, never electrical tape). Mate the integrated upper airframe/avbay assembly to the lower airframe/propulsion section, preferably with the rocket standing vertical on its fins to aid alignment. The rocket is fully integrated and ready to carry to the launch pad.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "GSE Setup", + "instructions": "Setup of the ground support equipment, which is very simple for a liquid rocket motor. May be performed concurrently with Section 7.3 Launchpad Operations. If using the minimum viable GSE, some steps will not apply; functionality remains the same regardless of GSE version. (HCR-5100 \u00a77.2.)", + "workcenter": "PAD", + "step_kit": [], + "sub_steps": [ + { + "title": "Gather required items", + "instructions": "Bring to the launchpad: nitrous oxide supply bottle with adapter to -04 AN flare installed; GSE box containing upstream fill line hose, downstream fill line tube with male QD fitting, servo cable, ignition cable, and QD clip with pre-attached Kevlar tether; two 9/16\" wrenches (adjustable acceptable); ratchet strap if using a bottle without siphon tube (the recommended 20 lbm Gas Cylinder Source bottle has no siphon tube and requires one, plus a stand or sturdy object to secure the inverted bottle); transmitter for GSE control; fire extinguisher if the site does not already have one near the pads.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Position and secure supply bottle", + "instructions": "Identify a suitable location adjacent to the launch rail. A siphon-tube bottle can sit on any reasonably flat surface; bottles without a siphon tube must be inverted and secured against tipping (the launch pad structure itself is often usable, as at FAR), so long as the bottle will not be directly exposed to the rocket's exhaust \u2014 behind the launch rail and away from the blast deflector. Strap snug with the ratchet strap if inverted; ensure the outlet adapter is accessible and pointed toward the GSE box, and the hand-valve handle is reachable. If securing an inverted bottle to the pad structure, the rail may need to be lowered to load the rocket first \u2014 in that case defer this step until the rocket is loaded and the rail raised vertical.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Position GSE box", + "instructions": "Place the GSE box adjacent to the launch rail on a flat surface out of the way of the blast deflector, with as clear a line of sight to the launch table or control bunker as possible. Orient the fill valve inlet so the nitrous supply line routes easily from the bottle.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect upstream fill line", + "instructions": "Thread the upstream fill line hose onto the oxidizer fill valve inlet on the GSE box and onto the supply bottle outlet, adjusting positions if it does not easily reach. Firmly tighten each B-nut with a wrench while backing up the male flared fittings with a second wrench.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect servo and ignition cables", + "instructions": "Plug the servo cable into the outlet marked \"SRV\" and the ignition cable into the outlet marked \"IGN\" on the GSE box.", + "caution": "Be sure the cables are plugged into the correct outlets. Do NOT swap outlets; doing so will damage the servos in the vehicle's valve assemblies. Obtain second-party verification that the cables are correctly configured before proceeding.", + "requires_signoff": true, + "schema": null + }, + { + "title": "Connect downstream fill line and stage QD hardware", + "instructions": "Insert the downstream fill line tube into the fluid outlet fitting on the GSE box; push firmly to fully engage the push-to-connect fitting and tug the tubing to verify retention by the internal teeth. Place the male-QD end of the downstream line and the QD clip in safe spots where they will not be damaged, stepped on, or exposed to dirt/debris before connection to the vehicle. Optionally, power the transmitter and GSE now for initial fill-valve and ignition-lead checkouts (also part of Section 7.3).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Stage fire extinguisher", + "instructions": "Place the fire extinguisher about 20 feet away from the launchpad (if the site does not already have extinguishers on hand).", + "caution": "NEVER approach a nitrous oxide tank which is on fire \u2014 this includes both the supply bottle and the vehicle propellant tank. Whenever possible, launch pad fires should be allowed to burn themselves out with personnel at a safe distance, unless the fire is very small and able to be safely extinguished. Fires not involving an N2O tank (dry grass, plant matter) are handled per the launch site's fire response procedures.", + "requires_signoff": false, + "schema": null + } + ], + "depends_on": [ + 1 + ] + }, + { + "title": "Launchpad Operations", + "instructions": "On-pad setup, GSE startup/checkouts, and vehicle arming. Similar to any high-power rocket except for the oxidizer fill and vehicle valve connections. Arming the vehicle is a hazardous operation and must not be performed until the launchpad is cleared of personnel not directly involved. (HCR-5100 \u00a77.3.)", + "workcenter": "PAD", + "step_kit": [ + { + "part": "nitrous_oxide", + "qty": 20 + } + ], + "sub_steps": [ + { + "title": "Load vehicle onto rail", + "instructions": "Carry the fully packed and integrated vehicle to the launchpad and lower the rail to horizontal if not already. Slide the lower rail guide into the rail slot keeping the vehicle colinear with the rail (excess torquing may damage guide and/or rail), guide the vehicle down until the upper rail guide enters the slot, then slide fully down until it bottoms out on the rail stop (or use a block of wood to stand the nozzle off from the blast deflector).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Raise and aim rail", + "instructions": "Raise the launch rail to vertical and lock if it has a locking feature. If the rail is angled, ensure it points away from the flight line. A launch angle of 2-5 degrees downrange is recommended.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "launch_rail_angle_and_direction", + "type": "text", + "label": "Launch rail angle and direction", + "required": true + } + ] + } + }, + { + "title": "Connect quick disconnect and tether", + "instructions": "Plug the male QD (GSE side) into the female QD (vehicle side). Slide the QD clip over the mated quick disconnect with the tether side facing the ground and the opening facing upwards; if the clip will not stay in place, a 1/4-1/2\" wide strip of masking tape over the clip opening may hold it without affecting separation. Tie the clip tether to the launchpad or other secure object \u2014 ideally slightly taut or with minimal slack such that the clip will be disengaged within the first 2-3 inches of the rocket's motion.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Connect servo cable to vehicle", + "instructions": "Plug the 3-pin servo cable into the vehicle valve servo wire, observing that all wire colors match (some cables use WHITE=YELLOW, RED=RED, BLACK=BROWN). Tape, tie, or zip-tie the servo cable to the launchpad or other secure object so it unplugs from the vehicle Y-harness connector when the vehicle departs the rail.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Power on transmitter, then GSE", + "instructions": "Flip all transmitter switches UP, ensure the LEFT stick is fully DOWN, and slide the transmitter power switch ON. After boot, flip all switches DOWN and close the switch covers. Then flip the GSE box power switch ON and verify the transmitter establishes a connection with the receiver after a few seconds.", + "caution": "The transmitter must be booted up before supplying power to the GSE.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Confirm supply bottle hand valve closed (twice, with second party)", + "instructions": "If the system has been pressurized since the upstream fill line hose was last connected, verify the upstream line between supply bottle and fill valve has been vented. Manually confirm that the oxidizer supply bottle hand valve is closed. Manually confirm again that it is closed. A second-party verification is strongly recommended, especially with an inverted bottle where the direction of valve rotation appears reversed. Failure to comply with this step may lead to unsafe pressurization of the rocket in close proximity to personnel.", + "caution": "Failure to comply may lead to unsafe pressurization of the rocket in close proximity to personnel.", + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "bottle_hand_valve_confirmed_closed_by", + "type": "text", + "label": "Bottle hand valve confirmed closed by second party", + "required": true + } + ] + }, + "required_role": "QA" + }, + { + "title": "Fill valve and vehicle valve checkouts", + "instructions": "Flip the FILL switch UP and visually confirm the fill valve opens; flip FILL DOWN and visually confirm it closes. Flip the SRV switch UP and confirm both vehicle valves open; flip SRV DOWN and confirm both close. Visual observation of the fuel valve may be impossible on the pad with the airframe installed \u2014 perform this checkout at least once prior to airframe integration with visual confirmation; on the pad, auditory confirmation of the servo sound is acceptable, by personnel familiar with the sound of a nominal actuation.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "fill_valve_open_close_confirmed", + "type": "text", + "label": "Fill valve open/close confirmed", + "required": true + }, + { + "name": "vehicle_fuel_and_oxidizer_valve_open", + "type": "text", + "label": "Vehicle fuel and oxidizer valve open/close confirmed", + "required": true + } + ] + } + }, + { + "title": "Spark check ignition system", + "instructions": "With the IGN switch DOWN, briefly touch the ignition leads together to confirm they do not spark. Flip IGN FULL UP and briefly touch the leads together to confirm they DO spark. Flip IGN FULL DOWN and confirm the leads do not spark.", + "caution": null, + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "spark_check_results_no_spark_spark_no", + "type": "text", + "label": "Spark check results (no spark / spark / no spark)", + "required": true + } + ] + } + }, + { + "title": "Arm deployment controllers", + "instructions": "Only proceed when a launch window is imminent \u2014 do not arm before a launch window is certain and all personnel will be immediately vacating the launchpad area. Turn on the primary deployment controller by twisting its power wires together (minimum five full twists), wrapping the connection with electrical tape over all exposed wire. Repeat for the secondary deployment controller.", + "caution": "Eye protection is required for all personnel at or near the pad during this operation. Live pyrotechnic charges can present a hazard in the event of misconfiguration or malfunction of the deployment controllers.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Confirm ejection charge continuity and transmitter custody", + "instructions": "Confirm that both deployment controllers read continuity of both drogue and main ejection charges. Confirm that the Operator has physical possession of the GSE control transmitter and that all switch covers are closed before proceeding.", + "caution": null, + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "continuity_readback_a_drogue_a_main_b", + "type": "text", + "label": "Continuity readback: A-drogue, A-main, B-drogue, B-main", + "required": true + } + ] + } + }, + { + "title": "Connect ignition leads \u2014 igniter armed", + "instructions": "After touching the ignition leads together to confirm no spark, connect one clip of the ignition leads to each leg of the igniter wire. Ensure the leads and/or alligator clips cannot contact each other or any conductive surface such as the launch rail; insulate igniter leads with electrical tape as required. WARNING: The igniter is now armed.", + "caution": "WARNING: The igniter is now armed.", + "requires_signoff": true, + "schema": null, + "required_role": "TEST" + }, + { + "title": "Clear pad, start cameras", + "instructions": "Wait until all other personnel are walking away from the pad to a safe distance or bunker \u2014 including unrelated personnel at other launch pads who may pass near on their way to the observation area. Start any cameras or other data collection devices at the pad.", + "caution": null, + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "cameras_data_collection_devices_started", + "type": "text", + "label": "Cameras / data collection devices started", + "required": true + } + ] + } + }, + { + "title": "Open supply bottle \u2014 oxidizer fill system armed", + "instructions": "Fully open the oxidizer supply bottle hand valve. It is recommended to back off the valve approximately 1/4 turn from fully open so the handle rotates easily \u2014 this prevents a full-open bottle from being mistaken for closed. WARNING: The oxidizer fill system is now armed. Proceed calmly from the pad to the launch control center.", + "caution": "WARNING: The oxidizer fill system is now armed.", + "requires_signoff": true, + "schema": null + } + ], + "depends_on": [ + 2 + ] + }, + { + "title": "Launch Procedure", + "instructions": "Load-and-go launch: a short, fast-paced sequence best done with a two-person team, a Director and an Operator (one person may perform both roles solo). More than two people are not required, and additional team members should not be involved in the fill and launch operation, to keep communication with the LCO and RSO clear and concise. (HCR-5100 \u00a77.4.)", + "workcenter": "PAD", + "step_kit": [], + "sub_steps": [ + { + "title": "Procedure review", + "instructions": "Both the Director and Operator review the entire launch section before performing the actual fill and fire procedures, so that when the time comes they already know what to do without needing to reference the guidebook. The sequence must be carried out with confidence to avoid wasting oxidizer or creating confusion.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Predetermine fill criteria", + "instructions": "Before launch operations, decide the criteria for concluding oxidizer fill: either a predetermined time has elapsed, or the static vent begins visibly expelling liquid nitrous oxide. Fill time for Mojave Sphinx is usually around 60 seconds, plus or minus 10 seconds depending on ambient temperature; any change to tank volume or static vent orifice size changes fill time. Predetermine the fill criteria and stick to those condition(s). If using visual observation, orient the vent perpendicular to the observer \u2014 liquid venting presents as a sudden, distinct opaque, well-defined plume rather than a translucent wisp; close the GSE fill valve immediately upon observation of liquid venting. If operating from the recommended standoff distance rather than a protected bunker, a time-based fill criterion is recommended. If a third spotter with binoculars is used, the authority to call out liquid venting rests solely with one individual.", + "caution": null, + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "chosen_fill_criteria_time_and_or_liquid", + "type": "text", + "label": "Chosen fill criteria (time and/or liquid venting)", + "required": true + }, + { + "name": "planned_fill_time", + "type": "text", + "label": "Planned fill time", + "required": true + } + ] + } + }, + { + "title": "LCO coordination and go-ahead", + "instructions": "Inform the LCO that the rocket is ready to begin fill operations. Communicate: the fill and ignition system is controlled entirely from the R/C transmitter (no club launch-controller buttons needed); nitrous fill will take about 60 seconds; after fill is completed the LCO should give a prompt 5-second countdown. Only proceed once a positive go-ahead has been given by the LCO to begin filling oxidizer. Operations may only proceed when all personnel are at a safe standoff distance or inside of bunkers. Do not begin filling the rocket if there are any personnel within the safe standoff distance.", + "caution": "Do not begin filling the rocket if there are any personnel within the safe standoff distance.", + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "lco_go_ahead_received", + "type": "text", + "label": "LCO go-ahead received", + "required": true + } + ] + } + }, + { + "title": "Begin oxidizer fill", + "instructions": "OPERATOR: Push the LEFT stick FULL UP calling out \"Stick up.\" OPERATOR: Flip the FILL switch UP calling out \"Fill valve open.\" DIRECTOR: Note fill start time on a time counter (a phone video's time counter works). DIRECTOR: Call out fill time elapsed and remaining every 15 seconds.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "fill_start_time", + "type": "text", + "label": "Fill start time", + "required": true + }, + { + "name": "elapsed_time_callouts_every_15_s", + "type": "text", + "label": "Elapsed-time callouts every 15 s", + "required": true + } + ] + } + }, + { + "title": "Conclude fill \u2014 fill valve close", + "instructions": "DIRECTOR: When the fill criteria has been met, call out \"Fill complete.\" OPERATOR: Flip the FILL switch DOWN calling out \"Fill valve close.\"", + "caution": null, + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "actual_fill_duration_criteria_met_time", + "type": "text", + "label": "Actual fill duration / criteria met (time or liquid venting)", + "required": true + } + ] + } + }, + { + "title": "Hold constraint after oxidizer fill", + "instructions": "If the 5-second launch countdown cannot commence immediately (e.g., range violation by an aircraft or vehicle), the rocket may be allowed to hold on the pad for up to 30 seconds. If launch does not occur within 30 seconds of closing the fill valve, either: reopen the fill valve for 5-10 seconds to increase the pressure in the tank, or abort the launch by opening the vehicle valves without firing the igniter, which will safely dump all propellant. A standard Mojave Sphinx tank loses approximately 4.5 psi per second from venting after the fill valve is closed. It is not recommended to proceed to ignition with a tank pressure lower than 400 psi (tank pressure is not typically measured when launching; on a hot day the allowable hold may be longer, but 30 seconds is a reasonable rule of thumb).", + "caution": "Do not proceed to ignition with a tank pressure lower than 400 psi.", + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "hold_duration_after_fill_valve_close_if", + "type": "text", + "label": "Hold duration after fill valve close, if any", + "required": true + }, + { + "name": "re_pressurization_or_abort_action_taken", + "type": "text", + "label": "Re-pressurization or abort action taken", + "required": true + } + ] + } + }, + { + "title": "Ready call and range check", + "instructions": "DIRECTOR: Confidently call out \"Ready for 5-count.\" DIRECTOR: Confirm acknowledgment by the LCO. Wait for the LCO or RSO to do a range check and begin countdown. Wait for the countdown to finish.", + "caution": null, + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "lco_acknowledgment_confirmed", + "type": "text", + "label": "LCO acknowledgment confirmed", + "required": true + } + ] + } + }, + { + "title": "Fire", + "instructions": "OPERATOR: Flip the IGN and SRV switches FULL UP. The rocket will either launch or dump its propellants. OPERATOR: After launch or dump is complete, flip all switches DOWN and close all covers. (With receiver failsafes set properly, turning off the transmitter accomplishes the same.)", + "caution": null, + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "launch_or_propellant_dump_outcome", + "type": "text", + "label": "Launch or propellant dump outcome", + "required": true + } + ] + }, + "required_role": "TEST" + }, + { + "title": "Flight observation \u2014 ascent and apogee event", + "instructions": "Once launched the rocket proceeds as any other high-power rocket; Mojave Sphinx has no onboard propulsion control and will burn until fuel depletion. Visually track the vehicle as it ascends (\"keep eyes on the rocket\"); from bunkers this may not be possible until the RSO permits exit. Liquid systems generally produce no smoke trail after burnout, so tracking through the full profile may be difficult. Confirm that the vehicle has successfully fired its apogee deployment charge and separated into two segments connected by shock cord. If this is not confirmed, make the RSO aware of a possible ballistic return and ensure that all persons are inside of bunkers if available. Note: Half Cat style rockets with unshrouded thrust structures tend to fall sideways after apogee if the drogue fails, slowing the fall \u2014 but this must not be considered a safe descent mode; always treat an unseparated rocket as a heads-up event that can pose a risk to people and property.", + "caution": "If apogee separation is not confirmed, notify the RSO of a possible ballistic return and ensure all persons are inside of bunkers if available.", + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "apogee_deployment_separation_confirmed", + "type": "checkbox", + "label": "Apogee deployment/separation confirmed", + "required": true + } + ] + } + }, + { + "title": "Flight observation \u2014 descent and touchdown", + "instructions": "Visually track the vehicle as it descends; be aware the LCO may launch other rockets while it descends under parachute. Watch for firing of the main deployment charge \u2014 altitude is difficult to judge by eye; depending on the deployment controller setting (typically around 1000 feet), the vehicle may appear closer to the ground than it truly is. If the main does not deploy, the vehicle will be moving considerably faster at touchdown. If wind brings the vehicle back toward the flight line, make sure all persons are aware there is an incoming rocket; if it appears the rocket will land close to people, loudly announce \"heads up.\" Note the line-of-sight to touchdown: distant landmarks in line with the landing point, and/or a compass reading, so a vector can be drawn to its location \u2014 recommended even with a GPS or radio tracker installed, in case the tracker loses power or malfunctions.", + "caution": "Even at a \"safe\" nominal descent rate, collision of the rocket under parachutes with a person, structure, or vehicle has the potential to cause harm.", + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "main_deployment_observed_altitude", + "type": "text", + "label": "Main deployment observed (altitude estimate)", + "required": true + }, + { + "name": "touchdown_bearing_landmark_line_and", + "type": "text", + "label": "Touchdown bearing / landmark line and approximate distance", + "required": true + } + ] + } + } + ], + "depends_on": [ + 3 + ], + "strict_sequence": true + }, + { + "title": "Launchpad Shutdown", + "instructions": "Shut down the pad promptly after firing \u2014 the oxidizer supply bottle is still open, and accidental opening of the fill valve can cause the fill line to whip violently. Only cross the flight line after the RSO has given explicit permission or declared the range clear. If the rocket is still on the pad after an oxidizer fill of any duration, use the Aborted Firing Safe-Down contingency operation \u2014 do NOT approach the rocket without completing a safety walk. (HCR-5100 \u00a77.5.)", + "workcenter": "PAD", + "step_kit": [], + "sub_steps": [ + { + "title": "Nominal shutdown \u2014 approach and listen", + "instructions": "After the RSO has given permission or declared all-clear, calmly proceed toward the launchpad. Stop about 20 feet away and listen for any hissing sounds (ask others to remain quiet). If hissing is heard, attempt to identify the location(s) and stay clear \u2014 hissing indicates a leak (burst line, damaged fill valve, or loose fitting). If the rocket has left the pad, leaks are only a minor hazard and will stop when the supply bottle is closed.", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "hissing_leak_observations_at_20_ft", + "type": "text", + "label": "Hissing/leak observations at 20 ft", + "required": true + } + ] + } + }, + { + "title": "Close supply bottle", + "instructions": "Approach the pad and fully close the oxidizer supply bottle hand valve. If leaks were present, step away and wait for them to subside.", + "caution": null, + "requires_signoff": true, + "schema": null + }, + { + "title": "Vent upstream fill line", + "instructions": "Physically confirm that the oxidizer supply bottle is closed. Place the end of the fill line on the ground and secure it by standing on it (\"whip-checking\") with the QD fitting protruding slightly from under the shoe sole; ask others to step back a few feet. Call out \"Venting!\" and open the fill valve to vent the upstream line \u2014 there will be a brief, loud hissing. If the fill valve fully opens and the hissing does not stop within two seconds, close the fill valve and ensure that the supply bottle is fully closed. Alternatively, vent by cracking the fitting connecting the line to the bottle with a wrench, loosening slowly as the hissing dies down until fully vented, then re-torque or remove the fitting.", + "caution": "The thrust force of the 1/4\" fill line and QD fitting is low enough to secure under a shoe without risk of injury; this does NOT necessarily apply to larger-diameter or higher-pressure lines. If restraining with a clamp or short tether instead, all personnel stand clear by at least twice the fill line length in case the restraint fails.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Power down", + "instructions": "Flip the GSE box power switch OFF, then slide the transmitter power switch OFF. The launchpad is now safe.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ], + "depends_on": [ + 4 + ] + }, + { + "title": "Recovery", + "instructions": "After launch, Mojave Sphinx is recovered like any other high-power rocket. No pressure remains in the tank or feed system because the valves stay open, and all propellants are fully expended (save a small amount of residual fuel that may drip from the chamber). (HCR-5100 \u00a77.6.)", + "workcenter": "PAD", + "step_kit": [], + "sub_steps": [ + { + "title": "Await RSO clearance and locate vehicle", + "instructions": "After the vehicle has landed, await instructions from the RSO. When the RSO gives permission to enter the range, proceed in the direction the vehicle was last seen. If a radio beacon or GPS tracker is installed, follow the signal; otherwise draw a vector from the observer's position on the flight line to a distant landmark in line with the rocket's last sighted position.", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Field hazards \u2014 temperature, terrain, personnel", + "instructions": "The thrust chamber assembly will remain quite hot for several minutes after firing (its substantial thermal mass retains heat longer than some commercial solid casings); the propellant tank will be cold from rapid nitrous expansion but warms quickly in the sun. Be mindful of extreme component temperatures if recovering shortly after landing. Travel to the landing site with at least two people (buddy system). Dress appropriately for the weather and carry plenty of water \u2014 especially critical at desert launch sites in summer; when temperatures exceed 110\u00b0 F, the danger posed by severe dehydration and rapid-onset heat exhaustion cannot be overstated. Exercise caution regarding natural hazards such as rough terrain or venomous wildlife.", + "caution": "Thrust chamber assembly remains hot for several minutes after firing; propellant tank may be frostbite-cold.", + "requires_signoff": false, + "schema": null + }, + { + "title": "Document landing condition", + "instructions": "Upon locating the vehicle, document its condition with photos before it is disturbed \u2014 helpful for identifying landing damage or diagnosing anomalies later. Optional: if the deployment controllers report the recorded apogee via audible beeps, listen for and record the result (a video recording provides a crude data backup).", + "caution": null, + "requires_signoff": false, + "schema": { + "fields": [ + { + "name": "photos_of_vehicle_as_landed_condition", + "type": "text", + "label": "Photos of vehicle as-landed condition", + "required": true + }, + { + "name": "reported_apogee_from_deployment", + "type": "text", + "label": "Reported apogee from deployment controller beep-out (optional)", + "required": true + } + ] + } + }, + { + "title": "Disarm and pack out", + "instructions": "Carefully untuck the two pairs of avbay power wires and untwist the splices to turn off the deployment controllers; insulate the leads with electrical tape if possible to prevent accidental re-activation. Optional: stuff the parachutes, chute protectors, and shock cord back into the tubes and slide the vehicle back together to carry as a single piece (a tote bag is convenient for parachutes and shock cords). Carry the rocket back to the flight line; once returned, it may be prepared for another launch.", + "caution": null, + "requires_signoff": false, + "schema": null + } + ], + "depends_on": [ + 5 + ] + }, + { + "title": "Aborted Firing Safe-Down", + "instructions": "If the fill valve was opened at any point with the fill line connected, the vehicle tank may still contain pressure. Two paths: if the vehicle valves were opened to expel propellant (the load-and-go abort), shut down and safe the igniter; if the valves could not be opened, the check valve leaves the static vent as the only offload path \u2014 wait out the boil-off, then approach through the staged safety-walk gates. (HCR-5100 \u00a77.5.2\u2013\u00a77.5.3.)", + "workcenter": "PAD", + "is_contingency": true, + "step_kit": [], + "sub_steps": [ + { + "title": "Propellant dumped \u2014 shut down and safe the igniter", + "instructions": "If the fill valve was opened at any point with the fill line connected, the vehicle tank may still contain pressure. If the vehicle valves were opened to expel propellant (per load-and-go): follow the Nominal Pad Shutdown steps, then disconnect the ignition leads from the igniter wires and twist the igniter wires together. Be mindful of expelled fuel on and around the pad. NEVER bring a heat source, sparks, or open flame near liquid fuels. Hydrocarbon fuel (e.g., diesel) will not evaporate \u2014 mop up with rags or oil-absorbent mats and dispose of responsibly.", + "caution": "NEVER approach a nitrous oxide tank which is on fire (supply bottle or vehicle tank), and no one approaches for a minimum of two minutes following the last indication of fire or smoke (\u00a71.4.4). Whenever possible let fires burn out with personnel at a safe distance, unless very small and safely extinguishable.", + "requires_signoff": true, + "schema": null + }, + { + "title": "Vehicle valves could not be opened \u2014 wait out boil-off", + "instructions": "If the vehicle valves were not opened, the propellant tank may be partially or completely filled with oxidizer; the oxidizer tank check valve prevents backflow, so the only offload path is the static vent. A full load of liquid nitrous takes an estimated 1-1.5 hours to boil off through the vent. Note the approximate time when oxidizer fill concluded. Wait at least one hour if the tank was completely filled; if only partially filled, this may be reduced according to the percentage of the nominal fill duration completed. (Loss of power/connection to the vehicle valves between start of loading and ignition has never occurred in over 50 firings of Half Cat style rockets; transmitter-GSE communication loss automatically safes the system with properly configured receiver failsafes.)", + "caution": null, + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "time_oxidizer_fill_concluded", + "type": "text", + "label": "Time oxidizer fill concluded", + "required": true + }, + { + "name": "fill_percentage_estimate", + "type": "text", + "label": "Fill percentage estimate", + "required": true + }, + { + "name": "wait_time_before_approach", + "type": "text", + "label": "Wait time before approach", + "required": true + } + ] + } + }, + { + "title": "Safety walk \u2014 100-foot hold", + "instructions": "After the RSO gives permission to proceed downrange, calmly and slowly walk toward the launchpad. Stop about 100 feet away and listen for hissing (approach from the static-vent side; liquid oxidizer in the tank produces an audible hiss from the static vent). If static vent hissing is audible, do not approach further \u2014 return to the safe standoff distance or bunker and wait for the hissing to die down (a few minutes to over an hour depending on fill level, vent orifice size, and ambient temperature). Personnel MUST NOT approach closer than 100 feet until no hissing sound can be observed at that distance. During this time, DO NOT turn the transmitter off and back on: booting the transmitter requires the switches in the UP position, which will energize the igniter circuit and command the valves open \u2014 this could result in accidental ignition and launch if the system spontaneously regains functionality. If at least three attempts to remotely open the vehicle valves are unsuccessful, turn the transmitter off and leave it off until the vehicle and pad are completely safed.", + "caution": "DO NOT power-cycle the transmitter while the vehicle is loaded \u2014 booting energizes the igniter circuit and commands valves open, risking accidental ignition and launch.", + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "hissing_audible_at_100_ft", + "type": "checkbox", + "label": "Hissing audible at 100 ft", + "required": true + } + ] + }, + "required_role": "QA" + }, + { + "title": "Staged approach \u2014 50 ft / 20 ft / 10 ft", + "instructions": "Once static vent hissing is no longer audible from 100 feet, cautiously proceed and stop about 50 feet away; all personnel remain quiet and listen. If hissing is audible, hold and wait for it to die down. Once no hissing at 50 feet, proceed and stop about 20 feet away; listen again, holding if hissing is audible. Once no hissing at 20 feet, proceed. If there is still quiet hissing only audible within 10 feet, give the propellant tank 5 additional minutes to vent or until hissing has ceased entirely.", + "caution": null, + "requires_signoff": true, + "schema": { + "fields": [ + { + "name": "hissing_checks_at_50_ft_20_ft_10_ft", + "type": "text", + "label": "Hissing checks at 50 ft, 20 ft, 10 ft", + "required": true + } + ] + } + }, + { + "title": "Safe the pad", + "instructions": "When no hissing is audible from the static vent, follow the Nominal Pad Shutdown procedure beginning with closing of the oxidizer supply bottle hand valve. After the oxidizer supply tank has been closed, disconnect the ignition leads from the igniter wires and twist the igniter wires together.", + "caution": null, + "requires_signoff": true, + "schema": null + }, + { + "title": "Vehicle shutdown (unlaunched vehicle)", + "instructions": "Applies only if the vehicle was not launched and remains on the pad. Turn off all deployment controllers to disarm the pyrotechnic charges and insulate the altimeter power leads to prevent accidental re-arming. Command the vehicle valves closed so fuel does not leak when bringing the vehicle horizontal \u2014 always be mindful of fuel dripping from the nozzle even with the fuel valve fully closed. The vehicle may then be removed from the launch rail and prepared for another attempt.", + "caution": "Eye protection is required for all personnel at the pad when deployment controllers with pyrotechnic charges are armed.", + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Misfire / No-Ignition Recovery", + "instructions": "If the IGN and SRV switches were flipped and the rocket neither launched nor dumped its propellants, the pad still holds a loaded, armed vehicle. Diagnose only from the safe standoff distance; any approach goes through the Aborted Firing Safe-Down gates. (HCR-5100 \u00a77.4.5, \u00a77.5, \u00a77.3, \u00a77.1.2.)", + "workcenter": "PAD", + "is_contingency": true, + "step_kit": [], + "sub_steps": [ + { + "title": "Safe down before approach", + "instructions": "The rocket is still on the pad after an oxidizer fill \u2014 do NOT approach without completing a safety walk. From the standoff distance, attempt to dump propellant by commanding the vehicle valves open (the load-and-go abort path). Then complete the Aborted Firing Safe-Down contingency \u2014 propellant-dumped path if the valves opened, boil-off path if they could not be opened \u2014 before anyone approaches the vehicle.", + "caution": null, + "requires_signoff": true, + "schema": null + }, + { + "title": "Spark-check the ignition system", + "instructions": "With the pad safed and the ignition leads disconnected from the igniter wires: with the IGN switch DOWN, briefly touch the ignition leads together to confirm they do not spark; flip IGN FULL UP and briefly touch the leads together to confirm they DO spark; flip IGN FULL DOWN and confirm no spark (checkout per \u00a77.3).", + "caution": null, + "requires_signoff": true, + "schema": null + }, + { + "title": "Verify igniter continuity", + "instructions": "Check igniter continuity with a multimeter at the igniter wire leads. If there is no continuity, do not reuse the e-match: open the igniter cartridge, remove the e-match and igniter motor, and rebuild per the Vehicle Preparation igniter installation steps \u2014 continuity-check and shunt the new e-match before installing (\u00a77.1.2).", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Re-verify tank pressure floor before re-attempt", + "instructions": "A standard Mojave Sphinx tank loses approximately 4.5 psi per second through the static vent after the fill valve is closed (\u00a77.4.3). Before any re-attempt, re-verify the hold constraint: if launch cannot occur within 30 seconds of closing the fill valve, either reopen the fill valve for 5\u201310 seconds to restore tank pressure, or abort by opening the vehicle valves.", + "caution": "Do not proceed to ignition with a tank pressure lower than 400 psi.", + "requires_signoff": true, + "schema": null + } + ] + } + ], + "kit": [ + { + "part": "e85", + "qty": 2 + }, + { + "part": "a3_4t", + "qty": 1 + }, + { + "part": "1834", + "qty": 4 + }, + { + "part": "1318", + "qty": 1 + }, + { + "part": "52555t644", + "qty": 1 + }, + { + "part": "7619a11", + "qty": 1 + }, + { + "part": "9452k226", + "qty": 1 + }, + { + "part": "nitrous_oxide", + "qty": 20 + } + ] + }, + { + "key": "maintenance", + "name": "Periodic Inspection & Maintenance", + "description": "Preventive maintenance from the HCR-5100 maintenance schedule: per-firing and per-flight checks plus interval and on-condition replacements.", + "type": "op", + "output": null, + "ops": [ + { + "title": "Per-firing inspection", + "instructions": null, + "workcenter": "PAD", + "step_kit": [], + "sub_steps": [ + { + "title": "Chamber threaded rods/nuts", + "instructions": "Check tightness \u2014 prevents hot gas leakage and erosion at the chamber/nozzle joint. (Interval: Each firing.)", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Fuel injector orifices", + "instructions": "Verify orifices are unobstructed \u2014 prevents chamber hot spots or reduced performance from clogged fuel orifices. (Interval: Each firing.)", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Igniter pass-through hole", + "instructions": "Verify unobstructed and clear as needed \u2014 prevents reduced igniter burn time or difficulty installing the e-match due to soot buildup. (Interval: Each firing.)", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Per-flight inspection", + "instructions": null, + "workcenter": "PAD", + "step_kit": [], + "sub_steps": [ + { + "title": "Fin fasteners", + "instructions": "Check tightness \u2014 prevents erratic trajectory from excessive fin deflection in flight. (Interval: Each flight (or firing with fins installed).)", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Every 3 firings \u2014 seal replacement", + "instructions": null, + "workcenter": "PAD", + "step_kit": [], + "sub_steps": [ + { + "title": "Fuel bulkhead O-ring", + "instructions": "Replace \u2014 lack of maintenance may cause fuel leakage out of the tank. (Interval: 3 firings.)", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Propellant piston O-rings", + "instructions": "Replace \u2014 lack of maintenance may cause difficulty pushing the piston into the fuel tank. (Interval: 3 firings.)", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "Every 5 firings/launches \u2014 wear replacement", + "instructions": null, + "workcenter": "PAD", + "step_kit": [], + "sub_steps": [ + { + "title": "QD tube", + "instructions": "Replace \u2014 lack of maintenance may cause oxidizer leakage out of the tube and inability to fully load the tank. (Interval: 5 firings.)", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Shock cord (uninsulated)", + "instructions": "Replace \u2014 lack of maintenance may cause airframe/propulsion separation on descent. (Interval: 5 launches.)", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + }, + { + "title": "On-condition replacement", + "instructions": null, + "workcenter": "BENCH", + "step_kit": [], + "sub_steps": [ + { + "title": "Shock cord (insulated)", + "instructions": "Replace \u2014 lack of maintenance may cause airframe/propulsion separation on descent. (Interval: Tears or serious burns.)", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "Graphite gasket", + "instructions": "Replace \u2014 lack of maintenance may cause local melting due to hot gas leakage. (Interval: TCA disassembly.)", + "caution": null, + "requires_signoff": false, + "schema": null + }, + { + "title": "TCA flange plates", + "instructions": "Replace \u2014 deformed plates cause inability to re-assemble the TCA. (Interval: When deformation prevents TCA assembly.)", + "caution": null, + "requires_signoff": false, + "schema": null + } + ] + } + ], + "kit": [] + } + ] +} diff --git a/src/opal/seed_data/sphinx/project.json b/src/opal/seed_data/sphinx/project.json new file mode 100644 index 0000000..7121419 --- /dev/null +++ b/src/opal/seed_data/sphinx/project.json @@ -0,0 +1,46 @@ +{ + "attribution": "Demo content adapted from HCR-5100 \u2014 Mojave Sphinx Build, Integration, and Launch Guidebook, Half Cat Rocketry, published under the GPL.", + "project": { + "name": "Mojave Sphinx", + "description": "N2O/E85 self-pressurizing liquid bipropellant rocket \u2014 ~250-300 lbf, target apogee ~5100-5800 ft. Demo content adapted from HCR-5100 \u2014 Mojave Sphinx Build, Integration, and Launch Guidebook, Half Cat Rocketry, published under the GPL.", + "tiers": [ + { + "level": 1, + "name": "FLIGHT", + "code": "F", + "description": "Flight-critical hardware \u2014 pressure-bearing and recovery-critical parts, full traceability required" + }, + { + "level": 2, + "name": "GROUND", + "code": "G", + "description": "Ground support equipment \u2014 fill system, ignition, enclosure" + }, + { + "level": 3, + "name": "DEV", + "code": "D", + "description": "Commodity hardware, raw stock, consumables, tooling, templates, and jigs" + } + ], + "part_numbering": { + "prefix": "SPX", + "separator": "-", + "sequence_digits": 4, + "format": "{prefix}{sep}{tier_code}{sep}{sequence}" + }, + "categories": [ + "Propulsion", + "Fluid System", + "Valves", + "Igniter", + "Airframe", + "Recovery", + "Avionics", + "Consumables", + "GSE" + ], + "requirements": [], + "cad_directories": [] + } +} diff --git a/src/opal/seed_data/sphinx/requirements.json b/src/opal/seed_data/sphinx/requirements.json new file mode 100644 index 0000000..6e63738 --- /dev/null +++ b/src/opal/seed_data/sphinx/requirements.json @@ -0,0 +1,166 @@ +{ + "attribution": "Demo content adapted from HCR-5100 \u2014 Mojave Sphinx Build, Integration, and Launch Guidebook, Half Cat Rocketry, published under the GPL.", + "requirements": [ + { + "title": "Nominal thrust", + "statement": "The propulsion system shall produce approximately 250 to 300 lbf of thrust at the start of burn.", + "rationale": "Produces a visually impressive, easily tracked launch and a thrust-to-weight ratio high enough to launch safely in normally acceptable winds and reach safe rail exit velocity even with a poor-efficiency injector, low-performing fuels (such as diesel), or cold temperatures.", + "category": "performance", + "verification_method": "test", + "allocations": [ + "tca_assy" + ], + "state": "baselined" + }, + { + "title": "Nominal burn time", + "statement": "The motor shall burn for approximately five seconds at nominal conditions (3.2 to 5.9 s across the 40 to 110\u00b0F operating envelope).", + "rationale": "Sized so the rocket burns until virtually out of sight while staying within the target impulse class; burn time varies with N2O temperature per Table 2.6.", + "category": "performance", + "verification_method": "test", + "allocations": [ + "tca_assy" + ], + "state": "baselined" + }, + { + "title": "Total impulse class", + "statement": "The motor shall deliver a nominal total impulse of approximately 5000 N\u00b7s, near the L/M-class boundary (3900 to 5000 N\u00b7s across the operating envelope).", + "rationale": "Total impulse can fall into either L or M designation depending on injector efficiency and N2O temperature; sets the standoff distance class (L-M: 500 ft minimum).", + "category": "performance", + "verification_method": "analysis", + "allocations": [ + "vehicle" + ], + "state": "baselined" + }, + { + "title": "Nominal chamber pressure", + "statement": "The combustion chamber shall operate at approximately 250 to 300 psi at nominal conditions (220 to 340 psi across the operating envelope).", + "rationale": "Chamber pressure in psi and thrust in lbf are almost exactly the same magnitude for this design and trend together; sets injector stiffness and structural requirements.", + "category": "performance", + "verification_method": "test", + "allocations": [ + "tca_assy" + ], + "state": "baselined" + }, + { + "title": "Wind launch capability", + "statement": "The vehicle shall launch safely in any normally acceptable wind conditions (up to 20 mph).", + "rationale": "Nominal thrust-to-weight ratio is set high enough to guarantee a safe rail exit velocity in wind.", + "category": "environmental", + "verification_method": "analysis", + "allocations": [ + "vehicle" + ], + "state": "draft" + }, + { + "title": "Modification envelope for O:F and chamber pressure", + "statement": "Any modification to injector orifice number/size shall keep the O:F ratio and chamber pressure within approximately 15% of the original nominal values.", + "rationale": "Keeps modified configurations inside the tested envelope; HalfCatSim does not model orifice-size mixing effects, so efficiency must be estimated manually.", + "category": "performance", + "verification_method": "analysis", + "allocations": [ + "tca_assy" + ], + "state": "baselined" + }, + { + "title": "Minimum characteristic length (L*)", + "statement": "The combustion chamber characteristic length (L*) shall be kept above 10 inches minimum.", + "rationale": "Below this there is a risk the combustion chamber may ignite but fail to remain ignited.", + "category": "performance", + "verification_method": "analysis", + "allocations": [ + "tca_assy" + ], + "state": "baselined" + }, + { + "title": "Injector stiffness", + "statement": "Injector stiffness ((feed pressure \u2212 chamber pressure)/chamber pressure) shall be at least 30% for both fuel and oxidizer, with 60%+ generally recommended.", + "rationale": "Absorbs random pressure fluctuations and off-nominal conditions; below ~20% the design becomes unstable (and HalfCatSim may stop working properly). Displayed stiffness is at start of burn and declines over time.", + "category": "performance", + "verification_method": "analysis", + "allocations": [ + "tca_assy" + ], + "state": "baselined" + }, + { + "title": "Oxidizer tank static vent", + "statement": "The oxidizer run tank shall include a static vent (small hole, typically 0.020 to 0.047 in in diameter) at or near the top of the volume occupied by N2O.", + "rationale": "Guarantees the rocket always depressurizes itself given enough time, even in the event of total control failure \u2014 the vent is a built-in leak; also sets oxidizer fill time.", + "category": "functional", + "verification_method": "inspection", + "allocations": [ + "tank_assy" + ], + "state": "baselined" + }, + { + "title": "Oxidizer supply hose and fill valve (GSE)", + "statement": "The hose between the N2O supply bottle and the remotely actuated fill valve shall be the shortest length required (ideally 12 inches or shorter), no larger than 1/4\" nominal diameter, made entirely of oxidizer-compatible materials (PTFE, brass, stainless steel), and rated to a safe working pressure of at least 1000 psi; the fill valve is held to the same requirements.", + "rationale": "Minimizes the volume pressurized with N2O when the supply bottle is opened by hand \u2014 the only place N2O is permitted with personnel present; manufacturer working-pressure ratings include a large (typically 4X) safety factor. Whip checks strongly recommended.", + "category": "interface", + "verification_method": "inspection", + "allocations": [ + "gse_fill" + ], + "state": "baselined" + }, + { + "title": "Oxidizer temperature/pressure operating envelope", + "statement": "The vehicle shall be operated with supply-bottle/ambient temperatures between 40\u00b0F and 110\u00b0F, corresponding to tank pressures of approximately 425 to 1050 psi; below 40\u00b0F use a bottle warmer, above 110\u00b0F cool the bottle with ice or a wet towel.", + "rationale": "N2O is a saturated liquid whose pressure and density are set by temperature; Table 2.6 defines performance across this envelope and marks conditions outside it as not recommended.", + "category": "environmental", + "verification_method": "analysis", + "allocations": [ + "tank_assy" + ], + "state": "baselined" + }, + { + "title": "Minimum ignition tank pressure", + "statement": "The launch shall proceed to ignition only with a tank pressure of at least 400 psi (the tank loses approximately 4.5 psi per second of venting after fill valve closure, bounding the pad hold at approximately 30 seconds).", + "rationale": "Ensures adequate injection pressure at ignition; drives the 30-second hold rule and the repressurize-or-abort decision.", + "category": "safety", + "verification_method": "demonstration", + "allocations": [ + "gse" + ], + "state": "baselined" + }, + { + "title": "Target apogee", + "statement": "The vehicle shall reach an apogee of approximately 5100 to 5800 ft at full fill across the 40 to 100\u00b0F envelope (nominal ~5500 ft at 60\u00b0F, alcohol fuel, BASIC-6-6 injector); hydrocarbon fuels multiply altitude by ~0.8.", + "rationale": "Predicted performance per Table 2.6; short-filling may be used to respect FAA waiver altitude ceilings, cloud base, and recovery walk distance.", + "category": "performance", + "verification_method": "analysis", + "allocations": [ + "vehicle" + ], + "state": "tbr" + }, + { + "title": "Legal launch ceiling (FAA waiver)", + "statement": "The launch shall be conducted under a dedicated FAA waiver (Form 7711-2 with supplemental information per 14 CFR 101.29(a)), since any rocket above FAA Class 1 (propellant mass > 125 grams) requires one and club waivers cannot be used for independent liquid launches.", + "rationale": "Liquid bipropellants are experimental and not permitted at NAR/TRA sanctioned events; in practice launches are conducted at FAR or RRS in the Mojave Desert.", + "category": "safety", + "verification_method": "inspection", + "allocations": [], + "state": "baselined" + }, + { + "title": "Minimum standoff distance", + "statement": "All persons, occupied buildings, and vehicles shall be kept at least 500 feet from the rocket (L-M class) in all directions whenever it is hazardous, unless behind protection such as FAR/RRS bunkers; standoff is based on maximum potential total impulse of all loaded propellant.", + "rationale": "Standoff distance is the only effective precaution against the most serious hazards; distances are based on total stored propellant energy per impulse class (Table 1.4: A-G 200 ft up to >O 1000 ft).", + "category": "safety", + "verification_method": "demonstration", + "allocations": [], + "state": "baselined" + } + ] +} diff --git a/src/opal/seed_data/sphinx/risks.json b/src/opal/seed_data/sphinx/risks.json new file mode 100644 index 0000000..dd080b5 --- /dev/null +++ b/src/opal/seed_data/sphinx/risks.json @@ -0,0 +1,240 @@ +{ + "attribution": "Demo content adapted from HCR-5100 \u2014 Mojave Sphinx Build, Integration, and Launch Guidebook, Half Cat Rocketry, published under the GPL.", + "risks": [ + { + "key": "pressure_vessel_rupture_shrapnel", + "title": "Pressure vessel rupture / shrapnel", + "condition": "The rocket and GSE contain gas and saturated N2O at high pressure (tank pressures 425-1050 psi across the operating temperature range) within weight-optimized structures.", + "departure": "A pressurized component ruptures or a fitting/hose lets go, projecting shrapnel or releasing a cutting jet of escaping fluid.", + "asset_part": null, + "asset_text": "Personnel near the pad or test setup; vehicle and GSE hardware.", + "consequence": "Penetrating/laceration injuries from shrapnel or gas momentum; destroyed flight hardware.", + "probability": 2, + "impact": 4, + "description": "Keep all persons clear of the system whenever N2O or other pressurized gas is present anywhere other than the supply bottle; slowly open fittings to relieve trapped pressure keeping hands away from escaping gas; whip-check lines that may whip when vented; enforce minimum standoff distances (500 ft for L-M class).", + "source_note": "1.4.1: 'High pressure can cause hardware to break, projecting shrapnel in any direction at high velocities... escaping fluid acts like a knife or a bullet.'" + }, + { + "key": "stuck_pressurized_oxidizer_tank", + "title": "Stuck pressurized oxidizer tank", + "condition": "The oxidizer run tank can trap pressure if control of the vehicle valves is lost after fill.", + "departure": "A sealed, pressurized oxidizer tank cannot be commanded to depressurize and must be approached or forcibly vented.", + "asset_part": "tank_assy", + "asset_text": null, + "consequence": "Prolonged hazardous condition on the pad; potential rupture if unrelieved.", + "probability": 1, + "impact": 3, + "description": "A static vent (small hole, typically .020-.047\" diameter) at/near the top of the N2O volume is a built-in leak: even in the event of total control failure the rocket will always depressurize itself given enough time (est. 1-1.5 hours for a full tank); staged 100/50/20-ft listening approach before anyone closes with the vehicle.", + "source_note": "1.4.1: 'Even in the event of total control failure, the rocket will always depressurize itself... because the static vent is a built-in leak'; 7.5.2 boil-off and approach protocol." + }, + { + "key": "fuel_fire_at_the_pad", + "title": "Fuel fire at the pad", + "condition": "Flammable liquid fuel (E85/alcohol/kerosene) and pyrotechnic igniters are handled and loaded during vehicle preparation, and dumped propellant can pool at the pad after an abort.", + "departure": "Free fuel is ignited by open flame, sparks, or another ignition source and the fire spreads from a puddle or leak.", + "asset_part": null, + "asset_text": "Personnel, vehicle/GSE hardware, and nearby equipment, structures, or vegetation (brush fire).", + "consequence": "Burn injuries; hardware damage; secondary fires of surrounding equipment or vegetation.", + "probability": 2, + "impact": 3, + "description": "Keep open flame, sparks, and ignition sources away when handling fuel, especially with a known spill or suspected leak; keep a Class B extinguisher or bucket of water/sand on hand; let fires burn out with personnel at a safe distance unless very small and safely extinguishable; mop up non-evaporating hydrocarbon fuel after a dump.", + "source_note": "1.4.2: 'Fires can occur when free fuel is ignited and spread rapidly if there is a puddle or leak present.'" + }, + { + "key": "n2o_venting_onto_ignition_sources", + "title": "N2O venting onto ignition sources", + "condition": "N2O is an oxidizer that splits into nitrogen and oxygen and accelerates any fire it feeds.", + "departure": "N2O is vented near hot parts, embers, sparks, or open flame, or is mistakenly used on a fire, intensifying combustion.", + "asset_part": null, + "asset_text": "Personnel and anything burning nearby.", + "consequence": "Rapid fire acceleration and escalation of an otherwise minor fire.", + "probability": 2, + "impact": 4, + "description": "Do not vent N2O near hot parts, embers, sparks, or open flame; N2O must NEVER be used to fight fires despite appearing cold; keep all persons clear of the system when N2O is present anywhere other than the supply bottle.", + "source_note": "1.4.3: 'N2O will accelerate fires... N2O should NEVER be used to fight fires.'" + }, + { + "key": "contained_n2o_decomposition", + "title": "Contained N2O decomposition", + "condition": "N2O decomposes exothermically above ~1000\u00b0F (threshold lowered by catalysts such as fine metal particles/contaminants), and adiabatic compression in fast-acting valves or cavitation can locally reach that temperature.", + "departure": "Contained N2O decomposes, producing a rapid pressure spike and free hot oxygen that combusts with anything flammable.", + "asset_part": null, + "asset_text": "Personnel near the system; oxidizer tank, plumbing, and vehicle.", + "consequence": "Tank/line explosion with simultaneous oxidizer-fed fire; potentially lethal to anyone nearby.", + "probability": 2, + "impact": 5, + "description": "All N2O valves other than the supply bottle valve are actuated remotely when N2O is present; no N2O pumps around personnel; keep all persons clear of the system when N2O is present anywhere other than the supply bottle.", + "source_note": "1.4.4: 'decomposition temperature is normally around 1000\u00b0F, but a catalyst... can lower that threshold'; adiabatic compression in solenoid/poppet valves cited as most common trigger.", + "disposition": "mitigate", + "mitigation_issue": "n2o_cleanliness", + "residual": [ + 1, + 5 + ] + }, + { + "key": "delayed_decomposition_after_a_fire", + "title": "Delayed decomposition after a fire", + "condition": "A fire occurring around a vessel containing N2O gas \u2014 even at ambient pressure \u2014 can heat residual vapor toward decomposition.", + "departure": "Personnel approach a rocket or tank after a fire, and lingering heat triggers a delayed decomposition/tank explosion.", + "asset_part": null, + "asset_text": "Personnel approaching the vehicle after an anomaly; surrounding vegetation (brush fire risk while waiting).", + "consequence": "Tank explodes long after the initiating event, injuring responders.", + "probability": 2, + "impact": 5, + "description": "No persons may approach a rocket, test setup, or any other N2O tank for a minimum of two minutes following the last indication of fire or smoke unless thoroughly purged with inert gas (CO2); NEVER approach a burning N2O tank \u2014 leave it to burn out; have emergency fire response available if vegetation ignition is a risk.", + "source_note": "1.4.4: 'no persons may approach... for a minimum of two minutes following the last indication of fire or smoke'; 'approaching an inflamed N2O rocket is more hazardous than leaving it alone.'" + }, + { + "key": "bleve_on_sudden_tank_failure", + "title": "BLEVE on sudden tank failure", + "condition": "The run tank holds saturated liquid N2O under pressure.", + "departure": "A sudden large leak or bulkhead tear-out causes the liquid to flash to gas \u2014 a Boiling Liquid Expanding Vapor Explosion (BLEVE).", + "asset_part": null, + "asset_text": "Hardware and anything within debris-throw radius.", + "consequence": "Explosive energy release beyond an equivalently sized pressurized-gas container; increased damage and projected debris distance (almost certainly secondary to an initiating failure).", + "probability": 1, + "impact": 4, + "description": "Keep all persons clear of the system when N2O is present anywhere other than the supply bottle; standoff distances sized to total stored propellant energy.", + "source_note": "1.4.5: 'a BLEVE will almost certainly not be the primary hazardous event... but it will increase damage to hardware and the projected distance of any thrown debris.'" + }, + { + "key": "confined_fuel_air_explosion", + "title": "Confined fuel-air explosion", + "condition": "Fuel vapor and N2O gas can accumulate in confined, stagnant volumes during fueling or from gross leaks.", + "departure": "A fuel-air (or fuel-N2O) mixture reaches the explosive threshold in a confined space near an ignition source and detonates.", + "asset_part": null, + "asset_text": "Personnel and structures in the confined space.", + "consequence": "Detonation causing blast injuries and structural damage.", + "probability": 1, + "impact": 4, + "description": "Handle fuel only in well-ventilated areas (near an open roll-up door or outside) \u2014 solvent fuels evaporate slowly and disperse readily, making explosive mixtures unlikely in ventilation; keep all persons clear of the system when N2O is present anywhere other than the supply bottle.", + "source_note": "1.4.6: 'The only real risk... is when a gross leak of both fuel and N2O gas occurs in a confined space near an ignition source.'" + }, + { + "key": "inadvertent_e_match_initiation_electrical_fire", + "title": "Inadvertent e-match initiation / electrical fire", + "condition": "The system uses large low-voltage batteries, high-voltage sparker circuits, and electric initiators (e-matches).", + "departure": "A short, induced current, or stray charge fires an initiator or starts an electrical fire while personnel are close (e.g., hands near the nozzle during igniter hookup).", + "asset_part": null, + "asset_text": "Personnel handling igniters and avionics; vehicle wiring and GSE.", + "consequence": "Unintended pyrotechnic event \u2014 flame/hot gas exposure, burns; electrical shock; equipment fire.", + "probability": 2, + "impact": 3, + "description": "Keep e-matches and initiators shunted (leads twisted) until connection; always spark-check electrical leads (briefly touching supply wires together) before connecting initiators; keep high-voltage devices powered down until needed; insulate exposed wires; keep hands and body clear of the nozzle when connecting igniter leads.", + "source_note": "1.4.7/1.4.8: 'Always spark-check electrical leads... before connecting initiators'; 'keep hands and other body parts clear of the nozzle.'" + }, + { + "key": "burn_from_hot_components_or_accidental_pyro_discharge", + "title": "Burn from hot components or accidental pyro discharge", + "condition": "The thrust chamber assembly retains substantial heat for several minutes after firing, and pyrotechnic materials can ignite when mishandled.", + "departure": "A person touches a hot component or is exposed to flame/hot gas from an accidental pyrotechnic ignition.", + "asset_part": null, + "asset_text": "Personnel recovering or servicing the vehicle.", + "consequence": "Burn injuries.", + "probability": 3, + "impact": 2, + "description": "Feel for radiant heat with the back of the hand ~1/2 inch from the surface before quick-touch testing; cool hot metal with a small amount of water; keep hands clear of the nozzle during igniter lead connection; spark-check leads first.", + "source_note": "1.4.8 and 7.6: 'the thrust chamber assembly may be very hot after firing... place the back of your hand about half an inch away.'" + }, + { + "key": "n2o_cold_burns_on_skin_contact", + "title": "N2O cold burns on skin contact", + "condition": "Parts recently exposed to liquid N2O are extremely cold, and liquid N2O can escape at fittings (especially supply-bottle-to-hose and hose-to-fill-valve) when venting or loosening them.", + "departure": "Escaping or residual liquid N2O contacts skin, its rapid boiling absorbing heat from the tissue.", + "asset_part": null, + "asset_text": "Hands and exposed skin of personnel venting or disconnecting fittings.", + "consequence": "Frostbite injury.", + "probability": 2, + "impact": 2, + "description": "Keep hands and exposed skin away from the fitting being loosened; quick-touch feel cold parts to test temperature before handling.", + "source_note": "1.4.9: 'The rapid boiling of escaping liquid will absorb heat from anything it touches, which can cause frostbite.'", + "disposition": "accepted", + "acceptance_rationale": null + }, + { + "key": "n2o_fume_inhalation_indoors", + "title": "N2O fume inhalation indoors", + "condition": "Liquid fuels are volatile and continuously evaporate; cleaning agents and motor exhaust are also airborne chemicals.", + "departure": "Fumes build up in a closed room and are inhaled in quantity.", + "asset_part": null, + "asset_text": "Personnel handling fuel indoors.", + "consequence": "Headache, nausea, or respiratory irritation.", + "probability": 3, + "impact": 2, + "description": "Handle fuels only in well-ventilated areas (near an open roll-up door or outside); as a general rule avoid breathing any airborne chemicals.", + "source_note": "1.4.10: 'the fumes must build up in a closed room... only handling fuels in well-ventilated areas.'" + }, + { + "key": "asphyxiation_from_large_indoor_gas_release", + "title": "Asphyxiation from large indoor gas release", + "condition": "N2O and CO2 can rapidly displace air in a closed volume, and a warm bottle moved to a cold environment can develop a hand-valve leak from thermal contraction.", + "departure": "A large gas release indoors displaces breathable air.", + "asset_part": null, + "asset_text": "Personnel in the room (including a vehicle interior).", + "consequence": "Drowsiness up to asphyxiation from oxygen deficiency.", + "probability": 2, + "impact": 3, + "description": "Work with compressed gases only in well-ventilated areas; if a bottle leaks indoors, immediately evacuate, open doors/windows, close the bottle or take it outside, and run fans; always ensure bottle hand valves are fully tightened, especially when moving a warm bottle into an air-conditioned car or room.", + "source_note": "1.4.11: 'Venting a large volume of non-air gas in a closed room may result in drowsiness, or at an extreme, asphyxiation.'" + }, + { + "key": "premature_approach_to_an_energized_vehicle", + "title": "Premature approach to an energized vehicle", + "condition": "The igniter can fail to light the motor after oxidizer fill, or the vehicle valves can fail to open, leaving a loaded, armed rocket on the pad (hang-fire condition).", + "departure": "Personnel approach the loaded vehicle too soon, or a transmitter power-cycle energizes the igniter circuit and commands the valves open, causing accidental ignition/launch with people downrange.", + "asset_part": null, + "asset_text": "Recovery/safing personnel and range population.", + "consequence": "Accidental ignition and launch, or exposure to a pressurized oxidizer-loaded vehicle at close range.", + "probability": 1, + "impact": 4, + "description": "Load-and-go opens the vehicle valves to dump all propellant even if ignition does not occur; if valves cannot be opened, wait out static-vent boil-off (at least one hour for a full tank, est. 1-1.5 hours total) with a staged 100/50/20-foot listening approach, never approaching closer than 100 ft while venting is audible; DO NOT power-cycle the transmitter (boot requires switches UP, energizing igniter and valves); after three failed remote valve attempts, turn the transmitter off and leave it off until fully safed.", + "source_note": "7.5.2: 'this may take anywhere from a few minutes to over an hour... DO NOT turn the transmitter off and back on... could result in accidental ignition and launch.'" + }, + { + "key": "recovery_deployment_failure_ballistic_descent", + "title": "Recovery deployment failure / ballistic descent", + "condition": "The recovery system depends on pyrotechnic deployment events, shear pins, shock cords, and parachutes that degrade with use.", + "departure": "Drogue or main fails to deploy, the airframe fails to separate, or a worn shock cord snaps, resulting in a ballistic or partially-slowed return.", + "asset_part": "vehicle", + "asset_text": null, + "consequence": "High-speed impact \u2014 ballistic return is treated as a heads-up event that can pose a risk to people and property; propulsion section free-fall and major hardware damage.", + "probability": 2, + "impact": 4, + "description": "Dual redundant deployment controllers each with drogue and main charges, continuity-checked before launch; conservatively sized, ground-tested ejection charges and shear pins; parachute-cannon packing arrangement; shock cord replacement schedule (5 launches uninsulated); if separation is not confirmed at apogee, notify the RSO of a possible ballistic return and put all persons in bunkers; heads-up calls for rockets descending near people; bunkers at FAR for experimental launches.", + "source_note": "7.4.6: 'Always treat an unseparated rocket as a heads-up event that can pose a risk to people and property in the vicinity'; 2.7: worn shock cord 'can snap during deployment.'", + "disposition": "realized", + "realized_issue": "shock_cord" + }, + { + "key": "inadvertent_initiation_during_handling", + "title": "Inadvertent initiation during handling", + "condition": "Personnel are present near the vehicle during transport, handling, and pre-launch preparation while it contains fuel and pyrotechnic igniter materials (no oxidizer loaded).", + "departure": "An inadvertent initiation occurs during handling \u2014 noting that unlike a solid motor, a standard liquid motor cannot be ignited by accident during transport, handling, or pre-launch preparation (fuel sealed in the tank, no oxidizer loaded); the credible events are limited to pyrotechnic e-match/ejection-charge initiation.", + "asset_part": null, + "asset_text": "Personnel transporting and preparing the vehicle.", + "consequence": "Localized burns or pyrotechnic injury rather than motor ignition.", + "probability": 1, + "impact": 2, + "description": "Fuel is sealed in the tank and oxidizer is never loaded until all personnel are clear; e-matches and charges kept shunted until connection; spark checks before connecting initiators; deployment controllers armed only when a launch window is imminent.", + "source_note": "1.4: 'a standard liquid motor simply cannot be ignited by accident during transport, handling or pre-launch preparation; for one, its fuel is sealed away inside the tank, and secondly, no oxidizer is loaded.'" + }, + { + "key": "recovery_crew_field_injury", + "title": "Recovery crew field injury", + "condition": "Recovery operations take place on foot in desert terrain, often in extreme heat, with hot/cold vehicle components at the landing site.", + "departure": "Recovery personnel suffer heat illness, dehydration, wildlife encounter, or contact injury from extreme-temperature components.", + "asset_part": null, + "asset_text": "Recovery personnel.", + "consequence": "Severe dehydration and rapid-onset heat exhaustion (danger 'cannot be overstated' above 110\u00b0F); burns/frostbite; snakebite or terrain injury.", + "probability": 2, + "impact": 3, + "description": "Buddy system (at least two people) for recovery; dress for the weather and carry plenty of water; caution regarding rough terrain and venomous wildlife; radiant-heat/quick-touch checks before handling components.", + "source_note": "7.6: 'when temperatures exceed 110\u00b0 F, the danger posed by severe dehydration and rapid-onset heat exhaustion cannot be overstated.'", + "disposition": "watch", + "watch_observable": "Launch-day forecast temperature and recovery-area terrain/wildlife report", + "watch_threshold": "Forecast above 100 \u00b0F or recovery area beyond 1 mi from vehicle access", + "watch_contingency": "Delay recovery until evening; assign a two-person recovery team with water and first-aid kit." + } + ] +} diff --git a/src/opal/tui/api_client.py b/src/opal/tui/api_client.py index 014f210..e6fb1d7 100644 --- a/src/opal/tui/api_client.py +++ b/src/opal/tui/api_client.py @@ -288,10 +288,15 @@ def create_instance(self, data: dict[str, Any]) -> dict[str, Any]: resp.raise_for_status() return resp.json() - def start_step(self, instance_id: int, step_number: int) -> dict[str, Any]: - """Start a step.""" + def focus_step(self, instance_id: int, step_number: int) -> dict[str, Any]: + """Move the caller's cursor to a step (presence). + + The focus/document model has no separate "start": a user places their + cursor via /focus, then commits with /complete, /skip, or /signoff. + """ resp = self.client.post( - self._url(f"/procedure-instances/{instance_id}/steps/{step_number}/start"), + self._url(f"/procedure-instances/{instance_id}/focus"), + json={"step_number": step_number}, headers=self._headers(), ) resp.raise_for_status() @@ -309,10 +314,13 @@ def complete_step( resp.raise_for_status() return resp.json() - def skip_step(self, instance_id: int, step_number: int) -> dict[str, Any]: + def skip_step( + self, instance_id: int, step_number: int, reason: str | None = None + ) -> dict[str, Any]: """Skip a step.""" resp = self.client.post( self._url(f"/procedure-instances/{instance_id}/steps/{step_number}/skip"), + json={"reason": reason} if reason else {}, headers=self._headers(), ) resp.raise_for_status() @@ -330,11 +338,11 @@ def signoff_step( resp.raise_for_status() return resp.json() - def update_step_notes(self, instance_id: int, step_number: int, notes: str) -> dict[str, Any]: - """Update step notes.""" - resp = self.client.patch( + def add_step_note(self, instance_id: int, step_number: int, body: str) -> dict[str, Any]: + """Append a timestamped note to a step.""" + resp = self.client.post( self._url(f"/procedure-instances/{instance_id}/steps/{step_number}/notes"), - json={"notes": notes}, + json={"body": body}, headers=self._headers(), ) resp.raise_for_status() @@ -470,6 +478,14 @@ def update_issue(self, issue_id: int, data: dict[str, Any]) -> dict[str, Any]: resp.raise_for_status() return resp.json() + def sign_disposition(self, issue_id: int, data: dict[str, Any]) -> dict[str, Any]: + """Sign an issue disposition (releases its holds).""" + resp = self.client.post( + self._url(f"/issues/{issue_id}/disposition"), json=data, headers=self._headers() + ) + resp.raise_for_status() + return resp.json() + def list_comments(self, issue_id: int) -> list[dict[str, Any]]: """List comments on an issue.""" resp = self.client.get(self._url(f"/issues/{issue_id}/comments")) diff --git a/src/opal/tui/screens/dashboard.py b/src/opal/tui/screens/dashboard.py index 8dcd607..9eb4721 100644 --- a/src/opal/tui/screens/dashboard.py +++ b/src/opal/tui/screens/dashboard.py @@ -135,7 +135,7 @@ async def load_stats(self) -> None: parts_stat.update_value(str(parts.get("total", 0))) # Active executions - executions = client.list_instances(status="in_progress", page_size=1) + executions = client.list_instances(status="in_work", page_size=1) exec_stat = self.query_one("#exec-stat", StatCard) exec_stat.update_value(str(executions.get("total", 0))) diff --git a/src/opal/tui/screens/executions.py b/src/opal/tui/screens/executions.py index ef66826..e85b992 100644 --- a/src/opal/tui/screens/executions.py +++ b/src/opal/tui/screens/executions.py @@ -10,25 +10,77 @@ from opal.tui.api_client import get_client from opal.tui.widgets.form import ConfirmModal, FormGroup, FormModal +# Terminal step statuses in the focus/document model: work has committed and +# no further step action applies. IN_PROGRESS is never written by the server. +TERMINAL_STEP_STATUSES = {"completed", "skipped", "signed_off"} + + +def _execution_label(instance: dict[str, Any]) -> str: + """A work order's name for interface copy: the work order number, else the + procedure name + cut date — never the bare database id (F12).""" + wo = instance.get("work_order_number") + if wo: + return str(wo) + name = instance.get("procedure_name") or "execution" + cut = (instance.get("created_at") or "")[:10] + return f"{name} (cut {cut})" if cut else str(name) + + +def _kit_row_label(item: dict[str, Any]) -> str: + """One kit-availability row: readiness, part, need/have, OPAL numbers. + + Field names follow the API's KitAvailabilityItem (quantity_required / + quantity_available / is_available) — the previous required_quantity / + available_quantity spellings matched nothing and rendered 0/'?' always. + """ + pid = item.get("part_id", "?") + part = item.get("part_name", f"#{pid}") + required = item.get("quantity_required", 0) + available = item.get("quantity_available") + req_str = f"{required:g}" if isinstance(required, int | float) else str(required) + avail_str = f"{available:g}" if isinstance(available, int | float) else "?" + ready = item.get("is_available", False) + icon = "[OK]" if ready else "[!!]" + label = f" {icon} {part}: need {req_str}, have {avail_str}" + opals = [ + loc["opal_number"] for loc in item.get("available_locations", []) if loc.get("opal_number") + ] + if opals: + label += " @ " + ", ".join(opals) + return label -class StepNotesModal(FormModal): - """Modal for editing step notes.""" - form_title = "Step Notes" +def _err_detail(exc: Exception) -> str: + """Human-readable detail from an httpx error, unwrapping the API's 400/422 + body (a hold-blocker string, or a list of validation messages).""" + resp = getattr(exc, "response", None) + if resp is not None: + try: + detail = resp.json().get("detail") + except Exception: + detail = None + if isinstance(detail, list): + return "; ".join( + str(d.get("msg", d)) if isinstance(d, dict) else str(d) for d in detail + ) + if detail: + return str(detail) + return str(exc) - def __init__(self, current_notes: str = "", **kwargs: Any) -> None: - super().__init__(**kwargs) - self.current_notes = current_notes + +class StepNoteModal(FormModal): + """Modal for appending one timestamped note to a step.""" + + form_title = "Add Note" def build_form(self) -> ComposeResult: yield FormGroup( - "Notes", - TextArea(text=self.current_notes, id="field-notes"), + "Note", + TextArea(id="field-note"), ) def get_form_data(self) -> dict[str, Any] | None: - notes = self.query_one("#field-notes", TextArea).text.strip() - return {"notes": notes} + return {"body": self.query_one("#field-note", TextArea).text.strip()} class NCLogModal(FormModal): @@ -167,19 +219,23 @@ def compose(self) -> ComposeResult: status_icon = { "pending": "[ ]", "in_progress": "[>]", + "awaiting_signoff": "[?]", "completed": "[x]", + "signed_off": "[x]", "skipped": "[-]", + "on_hold": "[!]", }.get(status, "[ ]") prefix = "[C] " if is_contingency else "" line = f"{status_icon} {order}. {prefix}{title}" - # Show notes indicator - if self.step_exec and self.step_exec.get("notes"): - line += " [N]" + # Show notes indicator (notes is the list of appended note rows) + notes = self.step_exec.get("notes") if self.step_exec else None + if notes: + line += f" [N{len(notes)}]" # Show signoff indicator - if self.step_exec and self.step_exec.get("signed_off_by"): + if self.step_exec and self.step_exec.get("signed_off_by_id"): line += " [S]" yield Label(line, classes=f"step-line {status}") @@ -199,11 +255,11 @@ def compose(self) -> ComposeResult: yield Label("Steps", classes="section-title") yield VerticalScroll(id="steps-execution") yield Horizontal( - Button("Start Step", id="btn-start", variant="primary"), + Button("Focus Step", id="btn-focus", variant="primary"), Button("Complete Step", id="btn-complete", variant="success"), Button("Skip Step", id="btn-skip", variant="default"), Button("Sign Off", id="btn-signoff", variant="warning"), - Button("Notes", id="btn-notes", variant="default"), + Button("Add Note", id="btn-notes", variant="default"), Button("Log NC", id="btn-nc", variant="error"), classes="step-controls", ) @@ -244,7 +300,7 @@ def show_execution(self, instance: dict[str, Any], version_content: dict[str, An # Progress step_executions = instance.get("step_executions", []) - completed = sum(1 for s in step_executions if s.get("status") in ["completed", "skipped"]) + completed = sum(1 for s in step_executions if s.get("status") in TERMINAL_STEP_STATUSES) total = len(step_executions) progress = (completed / total * 100) if total > 0 else 0 content.mount( @@ -282,25 +338,45 @@ def _render_steps(self, instance: dict[str, Any], version_content: dict[str, Any widget = StepExecution(step, step_exec) steps_container.mount(widget) - def get_current_step(self) -> int | None: - """Get the current step number to work on.""" + def get_current_step_data(self) -> dict[str, Any] | None: + """The next actionable LEAF: the first non-terminal sub-step (or + childless op) still awaiting work — mirrors how the web dockbar picks + bar_step. An OP header over children is never the target: its COMPLETE + is children-gated (the server refuses it), and SKIP on it would commit + the whole OP. In the focus model there is no "in progress" — the cursor + rests on the first leaf whose COMPLETE/SKIP has not yet committed. + Steps awaiting sign-off are excluded (they belong to the sign-off + action).""" if not self.instance_data: return None - step_executions = self.instance_data.get("step_executions", []) + parent_orders = { + s.get("parent_step_order") + for s in step_executions + if s.get("parent_step_order") is not None + } for step in sorted(step_executions, key=lambda s: s.get("step_number", 0)): - if step.get("status") in ["pending", "in_progress"]: - return step["step_number"] + status = step.get("status") + if status in TERMINAL_STEP_STATUSES or status == "awaiting_signoff": + continue + is_leaf = (step.get("level") or 0) > 0 or step.get("step_number") not in parent_orders + if is_leaf: + return step return None - def get_in_progress_step(self) -> dict[str, Any] | None: - """Get the in-progress step execution data.""" + def get_current_step(self) -> int | None: + """Step number of the next actionable step (focus/complete/skip target).""" + step = self.get_current_step_data() + return step["step_number"] if step else None + + def get_signoff_step(self) -> int | None: + """Step number of the first step awaiting sign-off, if any.""" if not self.instance_data: return None step_executions = self.instance_data.get("step_executions", []) - for step in step_executions: - if step.get("status") == "in_progress": - return step + for step in sorted(step_executions, key=lambda s: s.get("step_number", 0)): + if step.get("status") == "awaiting_signoff": + return step["step_number"] return None def clear(self) -> None: @@ -330,18 +406,7 @@ def show_kit_availability(self, kit_data: dict[str, Any] | list[dict[str, Any]]) return for item in items: - pid = item.get("part_id", "?") - part = item.get("part_name", item.get("part_number", f"#{pid}")) - required = item.get("required_quantity", item.get("quantity", 0)) - available = item.get("available_quantity", item.get("available", "?")) - if isinstance(available, (int, float)): - ready = item.get("is_available", available >= required) - else: - ready = item.get("is_available", False) - icon = "[OK]" if ready else "[!!]" - panel.mount( - Label(f" {icon} {part}: need {required}, have {available}", classes="detail-row") - ) + panel.mount(Label(_kit_row_label(item), classes="detail-row")) def show_productions(self, productions: list[dict[str, Any]]) -> None: """Display production records in the panel.""" @@ -381,7 +446,7 @@ class ExecutionsScreen(Screen): BINDINGS = [ ("r", "refresh", "Refresh"), - ("space", "start_step", "Start"), + ("space", "focus_step", "Focus"), ("enter", "complete_step", "Complete"), ("escape", "go_back", "Back"), ] @@ -391,8 +456,8 @@ def compose(self) -> ComposeResult: Label("Executions", classes="screen-title"), Horizontal( Button("All", id="filter-all", variant="primary"), - Button("Pending", id="filter-pending"), - Button("In Progress", id="filter-in_progress"), + Button("Cut", id="filter-cut"), + Button("In Work", id="filter-in_work"), Button("Completed", id="filter-completed"), classes="filter-bar", ), @@ -423,8 +488,8 @@ def action_go_back(self) -> None: """Go back to dashboard.""" self.app.switch_screen("dashboard") - async def action_start_step(self) -> None: - """Start the current step.""" + async def action_focus_step(self) -> None: + """Move the cursor to the current actionable step (presence).""" detail = self.query_one("#execution-detail", ExecutionDetail) if not detail.instance_data: self.notify("Select an execution first", severity="warning") @@ -432,34 +497,31 @@ async def action_start_step(self) -> None: step_number = detail.get_current_step() if step_number is None: - self.notify("No step to start", severity="warning") + self.notify("No step to focus", severity="warning") return client = get_client(self.app.api_url) try: - client.start_step(detail.instance_data["id"], step_number) - self.notify(f"Started step {step_number}") + client.focus_step(detail.instance_data["id"], step_number) + self.notify(f"Focused step {step_number}") await self._reload_selected() except Exception as e: - self.notify(f"Error: {e}", severity="error") + self.notify(f"Error: {_err_detail(e)}", severity="error") async def action_complete_step(self) -> None: - """Complete the current step.""" + """Complete the current actionable step.""" detail = self.query_one("#execution-detail", ExecutionDetail) if not detail.instance_data: self.notify("Select an execution first", severity="warning") return - # Find in-progress step - step_executions = detail.instance_data.get("step_executions", []) - in_progress = [s for s in step_executions if s.get("status") == "in_progress"] - - if not in_progress: - self.notify("No step in progress", severity="warning") + # The focus model has no "in progress" status; the target is the first + # non-terminal step still awaiting work. + step_number = detail.get_current_step() + if step_number is None: + self.notify("No step to complete", severity="warning") return - step_number = in_progress[0]["step_number"] - client = get_client(self.app.api_url) try: client.complete_step(detail.instance_data["id"], step_number) @@ -467,7 +529,7 @@ async def action_complete_step(self) -> None: await self._reload_selected() await self.load_executions() except Exception as e: - self.notify(f"Error: {e}", severity="error") + self.notify(f"Error: {_err_detail(e)}", severity="error") async def _skip_step(self) -> None: """Skip the current step.""" @@ -488,74 +550,71 @@ async def _skip_step(self) -> None: await self._reload_selected() await self.load_executions() except Exception as e: - self.notify(f"Error: {e}", severity="error") + self.notify(f"Error: {_err_detail(e)}", severity="error") async def _signoff_step(self) -> None: - """Sign off on the in-progress step.""" + """Sign off on the step awaiting sign-off.""" detail = self.query_one("#execution-detail", ExecutionDetail) if not detail.instance_data: self.notify("Select an execution first", severity="warning") return - step = detail.get_in_progress_step() - if not step: - self.notify("No step in progress to sign off", severity="warning") + step_number = detail.get_signoff_step() + if step_number is None: + self.notify("No step awaiting sign-off", severity="warning") return client = get_client(self.app.api_url) try: - client.signoff_step(detail.instance_data["id"], step["step_number"]) - self.notify(f"Signed off step {step['step_number']}") + client.signoff_step(detail.instance_data["id"], step_number) + self.notify(f"Signed off step {step_number}") await self._reload_selected() + await self.load_executions() except Exception as e: - self.notify(f"Error: {e}", severity="error") + self.notify(f"Error: {_err_detail(e)}", severity="error") - async def _edit_notes(self) -> None: - """Edit notes for the in-progress step.""" + async def _add_note(self) -> None: + """Append a timestamped note to the current actionable step.""" detail = self.query_one("#execution-detail", ExecutionDetail) if not detail.instance_data: self.notify("Select an execution first", severity="warning") return - step = detail.get_in_progress_step() + step = detail.get_current_step_data() if not step: - self.notify("No step in progress", severity="warning") + self.notify("No actionable step", severity="warning") return - current_notes = step.get("notes", "") - self.app.push_screen( - StepNotesModal(current_notes=current_notes), - callback=self._on_notes_saved, - ) + self.app.push_screen(StepNoteModal(), callback=self._on_note_added) - def _on_notes_saved(self, data: dict[str, Any] | None) -> None: - """Handle notes save result.""" - if data is None: + def _on_note_added(self, data: dict[str, Any] | None) -> None: + """Handle add-note result.""" + if data is None or not data.get("body"): return detail = self.query_one("#execution-detail", ExecutionDetail) if not detail.instance_data: return - step = detail.get_in_progress_step() + step = detail.get_current_step_data() if not step: return client = get_client(self.app.api_url) try: - client.update_step_notes(detail.instance_data["id"], step["step_number"], data["notes"]) - self.notify("Notes updated") + client.add_step_note(detail.instance_data["id"], step["step_number"], data["body"]) + self.notify("Note added") self.run_worker(self._reload_selected()) except Exception as e: - self.notify(f"Error: {e}", severity="error") + self.notify(f"Error: {_err_detail(e)}", severity="error") async def _log_nc(self) -> None: - """Log a non-conformance for the current step.""" + """Log a non-conformance for the current actionable step.""" detail = self.query_one("#execution-detail", ExecutionDetail) if not detail.instance_data: self.notify("Select an execution first", severity="warning") return - step = detail.get_in_progress_step() + step = detail.get_current_step_data() if not step: - self.notify("No step in progress", severity="warning") + self.notify("No actionable step", severity="warning") return self.app.push_screen(NCLogModal(), callback=self._on_nc_logged) @@ -567,7 +626,7 @@ def _on_nc_logged(self, data: dict[str, Any] | None) -> None: detail = self.query_one("#execution-detail", ExecutionDetail) if not detail.instance_data: return - step = detail.get_in_progress_step() + step = detail.get_current_step_data() if not step: return client = get_client(self.app.api_url) @@ -575,8 +634,9 @@ def _on_nc_logged(self, data: dict[str, Any] | None) -> None: result = client.log_nc(detail.instance_data["id"], step["step_number"], data) issue_id = result.get("id", result.get("issue_id", "?")) self.notify(f"NC logged as issue #{issue_id}") + self.run_worker(self._reload_selected()) except Exception as e: - self.notify(f"Error: {e}", severity="error") + self.notify(f"Error: {_err_detail(e)}", severity="error") async def _reload_selected(self) -> None: """Reload the currently selected execution.""" @@ -603,9 +663,7 @@ async def load_executions(self, status: str | None = None) -> None: table.clear() for inst in instances: step_execs = inst.get("step_executions", []) - completed = sum( - 1 for s in step_execs if s.get("status") in ["completed", "skipped"] - ) + completed = sum(1 for s in step_execs if s.get("status") in TERMINAL_STEP_STATUSES) total = len(step_execs) progress = f"{completed}/{total}" @@ -632,8 +690,8 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: if status == "all": status = None await self.load_executions(status=status) - elif button_id == "btn-start": - await self.action_start_step() + elif button_id == "btn-focus": + await self.action_focus_step() elif button_id == "btn-complete": await self.action_complete_step() elif button_id == "btn-skip": @@ -641,7 +699,7 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: elif button_id == "btn-signoff": await self._signoff_step() elif button_id == "btn-notes": - await self._edit_notes() + await self._add_note() elif button_id == "btn-nc": await self._log_nc() elif button_id == "btn-kit": @@ -725,7 +783,9 @@ async def _finalize(self) -> None: self.app.push_screen( ConfirmModal( title="Finalize Execution", - message=f"Finalize execution #{detail.instance_data['id']}? This locks all records.", + message=( + f"Finalize {_execution_label(detail.instance_data)}? This locks all records." + ), confirm_label="Finalize", ), callback=self._on_finalize_confirmed, diff --git a/src/opal/tui/screens/issues.py b/src/opal/tui/screens/issues.py index fd708e7..8b92e04 100644 --- a/src/opal/tui/screens/issues.py +++ b/src/opal/tui/screens/issues.py @@ -60,9 +60,6 @@ def build_form(self) -> ComposeResult: if self.issue: status_options = [ ("Open", "open"), - ("Investigating", "investigating"), - ("Disposition Pending", "disposition_pending"), - ("Disposition Approved", "disposition_approved"), ("Closed", "closed"), ] yield FormGroup( @@ -137,12 +134,18 @@ def build_form(self) -> ComposeResult: ("Repair", "repair"), ("Scrap", "scrap"), ("Return to Supplier", "return_to_supplier"), + ("No Defect", "no_defect"), ] yield FormGroup( "Disposition Type", Select(disp_options, id="field-disposition", prompt="Select..."), required=True, ) + yield FormGroup( + "Rationale", + TextArea(id="field-rationale"), + required=True, + ) yield FormGroup( "Root Cause", TextArea(id="field-root-cause"), @@ -158,12 +161,17 @@ def get_form_data(self) -> dict[str, Any] | None: self.show_error("Disposition type is required") return None + rationale = self.query_one("#field-rationale", TextArea).text.strip() + if not rationale: + self.show_error("Rationale is required") + return None + root_cause = self.query_one("#field-root-cause", TextArea).text.strip() corrective_action = self.query_one("#field-corrective-action", TextArea).text.strip() data: dict[str, Any] = { - "status": "disposition_approved", "disposition_type": disposition, + "disposition_rationale": rationale, } if root_cause: data["root_cause"] = root_cause @@ -212,6 +220,11 @@ def show_issue(self, issue: dict[str, Any]) -> None: priority = issue.get("priority", "-") content.mount(Label(f"Priority: {priority}", classes=f"detail-row priority-{priority}")) + containment = issue.get("containment", "-") + content.mount( + Label(f"Containment: {containment}", classes=f"detail-row containment-{containment}") + ) + # Disposition info if issue.get("disposition_type"): content.mount(Label(f"Disposition: {issue['disposition_type']}", classes="detail-row")) @@ -291,9 +304,6 @@ def compose(self) -> ComposeResult: Horizontal( Button("All", id="filter-all", variant="primary"), Button("Open", id="filter-open"), - Button("Investigating", id="filter-investigating"), - Button("Disp Pending", id="filter-disposition_pending"), - Button("Disp Approved", id="filter-disposition_approved"), Button("Closed", id="filter-closed"), classes="filter-bar", ), @@ -395,6 +405,14 @@ async def action_disposition(self) -> None: if not detail.issue_data: self.notify("Select an issue first", severity="warning") return + # Advisory issues hold nothing, so there is no disposition to sign + # (the API 400s). They are resolved by closing — press 'c'. + if detail.issue_data.get("containment", "advisory") == "advisory": + self.notify( + "Advisory issue has no disposition — press 'c' to close", + severity="warning", + ) + return self.app.push_screen( DispositionModal(issue=detail.issue_data), callback=self._on_disposition, @@ -409,8 +427,18 @@ def _on_disposition(self, data: dict[str, Any] | None) -> None: return client = get_client(self.app.api_url) try: - client.update_issue(detail.issue_data["id"], data) - self.notify("Disposition recorded") + issue_id = detail.issue_data["id"] + extras = {k: v for k, v in data.items() if k in ("root_cause", "corrective_action")} + if extras: + client.update_issue(issue_id, extras) + client.sign_disposition( + issue_id, + { + "disposition_type": data["disposition_type"], + "disposition_rationale": data["disposition_rationale"], + }, + ) + self.notify("Disposition signed") self.run_worker(self.load_issues()) except Exception as e: self.notify(f"Error: {e}", severity="error") diff --git a/src/opal/web/routes.py b/src/opal/web/routes.py index 8178412..c8c9c5a 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -14,6 +14,7 @@ from sqlalchemy import case, func, or_ from opal.api.deps import DbSession +from opal.core import execution_flow as exec_flow from opal.core.auth import ( SESSION_COOKIE, authenticate_password, @@ -27,6 +28,7 @@ validate_password_strength, verify_payload, ) +from opal.core.holds import holding_readout, scope_label from opal.db.models import ( InventoryRecord, Kit, @@ -38,8 +40,8 @@ Workcenter, ) from opal.db.models.dataset import DataPoint, Dataset -from opal.db.models.execution import InstanceStatus, ProcedureInstance -from opal.db.models.issue import Issue, IssuePriority, IssueStatus, IssueType +from opal.db.models.execution import InstanceStatus, ProcedureInstance, StepExecution +from opal.db.models.issue import Containment, Issue, IssuePriority, IssueStatus, IssueType from opal.db.models.procedure import MasterProcedure, ProcedureStatus, ProcedureVersion from opal.db.models.purchase import PurchaseStatus from opal.db.models.requirement import Requirement @@ -51,6 +53,24 @@ TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) +STATIC_DIR = Path(__file__).parent / "static" + + +def static_url(path: str) -> str: + """/static URL with an mtime cache-buster. + + Pages stay open for days on shop-floor tablets; without a version + in the URL a normal reload can keep serving stale CSS/JS forever. + """ + try: + version = int((STATIC_DIR / path).stat().st_mtime) + except OSError: + return f"/static/{path}" + return f"/static/{path}?v={version}" + + +templates.env.globals["static_url"] = static_url + def status_value(status) -> str: """Get string value from status (handles both enum and string).""" @@ -61,6 +81,7 @@ def status_value(status) -> str: # Register custom filter templates.env.filters["status_value"] = status_value +templates.env.filters["initials"] = exec_flow.user_initials # Audit log display helpers _TABLE_URL_MAP: dict[str, str] = { @@ -558,12 +579,12 @@ def index(request: Request, db: DbSession) -> HTMLResponse: db.query(Issue) .filter( Issue.deleted_at.is_(None), - Issue.status.in_(["open", "investigating", "disposition_pending"]), + Issue.status == IssueStatus.OPEN, ) .count() ) context["in_progress_count"] = ( - db.query(ProcedureInstance).filter(ProcedureInstance.status == "in_progress").count() + db.query(ProcedureInstance).filter(ProcedureInstance.status == "in_work").count() ) context["risks_count"] = ( db.query(Risk) @@ -633,22 +654,6 @@ def index(request: Request, db: DbSession) -> HTMLResponse: recent_activity = db.query(AuditLog).order_by(AuditLog.timestamp.desc()).limit(15).all() context["recent_activity"] = recent_activity - # Traceability widget — red-only (overdue TBRs, stuck block-lint drafts) - # plus exactly one non-red line (ready-to-baseline count). - from opal.se.dashboard import ( - old_block_lint_drafts, - overdue_tbrs, - stale_draft_parts, - stale_requirements, - ) - from opal.se.readiness import ready_requirement_ids - - context["overdue_tbrs"] = overdue_tbrs(db) - context["stuck_drafts"] = old_block_lint_drafts(db) - context["stale_reqs"] = stale_requirements(db) - context["ready_to_baseline"] = len(ready_requirement_ids(db)) - context["stale_draft_parts"] = stale_draft_parts(db) - return templates.TemplateResponse("index.html", context) @@ -856,9 +861,7 @@ def parts_import(request: Request, db: DbSession) -> HTMLResponse: @router.get("/parts/new", response_class=HTMLResponse) -def parts_new( - request: Request, db: DbSession, parent_id: int | None = Query(None) -) -> HTMLResponse: +def parts_new(request: Request, db: DbSession, parent_id: int | None = Query(None)) -> HTMLResponse: """Deep link: the parts list with the create overlay open. The form never asks what the invoking context already knows — @@ -1139,11 +1142,19 @@ def inventory_opal_detail( opal_number: str, ) -> HTMLResponse: """OPAL item detail page with full traceability history.""" - from opal.db.models.inventory import InventoryProduction + from sqlalchemy.orm import joinedload, selectinload + + from opal.db.models.inventory import InventoryConsumption, InventoryProduction from opal.db.models.purchase import PurchaseLine record = ( db.query(InventoryRecord) + .options( + joinedload(InventoryRecord.part), + selectinload(InventoryRecord.consumptions).joinedload( + InventoryConsumption.procedure_instance + ), + ) .join(Part) .filter(InventoryRecord.opal_number == opal_number, Part.deleted_at.is_(None)) .first() @@ -1209,6 +1220,9 @@ def inventory_opal_detail( if hasattr(c.usage_type, "value") else c.usage_type, "procedure_instance_id": c.procedure_instance_id, + "work_order_number": c.procedure_instance.work_order_number + if c.procedure_instance + else None, "notes": c.notes, }, } @@ -1358,10 +1372,21 @@ def purchases_detail(request: Request, db: DbSession, purchase_id: int) -> HTMLR status_code=404, ) - context = get_base_context(request, db, f"PO-{purchase_id} - OPAL") + context = get_base_context(request, db, f"{purchase.reference or 'PO'} - OPAL") context["purchase"] = purchase context["statuses"] = [s.value for s in PurchaseStatus] + # Known stock locations feed the receive form's datalist — receiving + # into an existing location should not require retyping it. + context["known_locations"] = [ + row[0] + for row in db.query(InventoryRecord.location) + .filter(InventoryRecord.location.isnot(None)) + .distinct() + .order_by(InventoryRecord.location) + .all() + ] + # Expense ledger records (written at receive time) from opal.db.models import PurchaseExpense @@ -1531,6 +1556,7 @@ def procedures_detail( "id": k.id, "part_id": k.part_id, "part_name": k.part.name, + "part_internal_pn": k.part.internal_pn, "part_external_pn": k.part.external_pn, "quantity_required": float(k.quantity_required), } @@ -1552,6 +1578,7 @@ def procedures_detail( "id": o.id, "part_id": o.part_id, "part_name": o.part.name, + "part_internal_pn": o.part.internal_pn, "part_external_pn": o.part.external_pn, "quantity_produced": float(o.quantity_produced), } @@ -1899,7 +1926,7 @@ def executions_table( page: int = Query(1, ge=1), ) -> HTMLResponse: """Executions table rows (HTMX partial).""" - from opal.db.models.execution import StepExecution, StepStatus + from opal.db.models.execution import StepStatus # Aggregate step progress per instance and join the procedure/version # names so the table renders from a single query @@ -1933,7 +1960,7 @@ def executions_table( query = query.filter(ProcedureInstance.status == status) rows, pagination = paginate_query( - request, query.order_by(ProcedureInstance.id.desc()), page, colspan=7 + request, query.order_by(ProcedureInstance.id.desc()), page, colspan=6 ) instances_data = [] @@ -1981,29 +2008,22 @@ def executions_new(request: Request, db: DbSession) -> HTMLResponse: return templates.TemplateResponse("executions/new.html", context) -_EXECUTION_TABS = ("meta", "operations", "data", "bom", "issues", "kitting") +_EXECUTION_TABS = ("document", "data", "bom", "issues", "kitting") +# Old bookmarks/links: both dissolved tabs land on the document. +_EXECUTION_TAB_ALIASES = {"meta": "document", "operations": "document"} -@router.get("/executions/{instance_id}", response_class=HTMLResponse) -def executions_detail( +def _execution_detail_context( request: Request, db: DbSession, - instance_id: int, - op: int | None = None, - tab: str = "meta", -) -> HTMLResponse: - """Execution detail/run page.""" - instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() - if not instance: - return templates.TemplateResponse( - "errors/404.html", - {"request": request, "message": f"Execution {instance_id} not found"}, - status_code=404, - ) - + instance: ProcedureInstance, +) -> dict: + """Full context for the execution page and its partials (document rows, + rail, docked bar). One builder — partial routes render fragments of the + same facts.""" version = db.query(ProcedureVersion).filter(ProcedureVersion.id == instance.version_id).first() - context = get_base_context(request, db, f"Execution {instance_id} - OPAL") + context = get_base_context(request, db, f"{instance.work_order_number or 'Execution'} - OPAL") context["instance"] = instance context["version"] = version context["statuses"] = [s.value for s in InstanceStatus] @@ -2166,30 +2186,18 @@ def _se_status(se): context["ops"] = ops context["contingency_ops"] = contingency_ops - # Pick which op's steps to render on the right pane. - all_ops = ops + contingency_ops - valid_orders = {o["step"]["order"] for o in all_ops} - - def _pick_default_order() -> int | None: - if not all_ops: - return None - for o in all_ops: - if o["step"]["status"] == "in_progress": - return o["step"]["order"] - for o in all_ops: - if o["step"]["status"] not in ("completed", "signed_off", "skipped"): - return o["step"]["order"] - return all_ops[0]["step"]["order"] - - context["selected_op_order"] = op if op in valid_orders else _pick_default_order() - - context["tab"] = tab if tab in _EXECUTION_TABS else "meta" - # Map step order -> version step data (for data capture schemas, requires_signoff) context["version_steps_map"] = {s["order"]: s for s in version_steps} # Get kit information - kit_items = db.query(Kit).filter(Kit.procedure_id == instance.procedure_id).all() + from sqlalchemy.orm import joinedload + + kit_items = ( + db.query(Kit) + .options(joinedload(Kit.part)) + .filter(Kit.procedure_id == instance.procedure_id) + .all() + ) context["kit_items"] = kit_items # Get existing consumptions @@ -2201,7 +2209,8 @@ def _pick_default_order() -> int | None: consumptions = ( db.query(InventoryConsumption) - .filter(InventoryConsumption.procedure_instance_id == instance_id) + .options(joinedload(InventoryConsumption.inventory_record).joinedload(InventoryRecord.part)) + .filter(InventoryConsumption.procedure_instance_id == instance.id) .all() ) context["consumptions"] = consumptions @@ -2230,7 +2239,8 @@ def _pick_default_order() -> int | None: # Get existing productions productions = ( db.query(InventoryProduction) - .filter(InventoryProduction.procedure_instance_id == instance_id) + .options(joinedload(InventoryProduction.inventory_record).joinedload(InventoryRecord.part)) + .filter(InventoryProduction.procedure_instance_id == instance.id) .all() ) context["productions"] = productions @@ -2254,6 +2264,7 @@ def _pick_default_order() -> int | None: bom_items.append( { "part_id": k.part_id, + "part_pn": k.part.internal_pn, "part_name": k.part.name, "qty_required": qty_required, "qty_consumed": qty_consumed, @@ -2267,6 +2278,7 @@ def _pick_default_order() -> int | None: unplanned.append( { "part_id": pid, + "part_pn": inv_c.inventory_record.part.internal_pn if inv_c else None, "part_name": inv_c.inventory_record.part.name if inv_c else "Unknown", "qty_consumed": qty, } @@ -2274,14 +2286,33 @@ def _pick_default_order() -> int | None: context["bom_items"] = bom_items context["unplanned_consumptions"] = unplanned + # Material relevance (empty-state rule): declared intent decides where + # content is expected; data always renders. Two predicates, one home each: + # - kit_relevant (BOM tab): parts IN — a kit (procedure or step level) or + # actual consumptions. Reconciliation is meaningless without a kit side. + # - material_relevant (KITTING tab): parts in OR out — the tab is also + # home to PRODUCTIONS and the FINALIZE PRODUCTION control, so an + # output-only procedure (ProcedureOutput, no kit) still shows it (F7); + # hiding it strands WIP productions with FINALIZE unreachable. + kit_relevant = bool( + kit_items + or consumptions + or any(vs.get("step_kit") for vs in context["version_steps_map"].values()) + ) + context["kit_relevant"] = kit_relevant + context["material_relevant"] = kit_relevant or bool(productions or output_items) + # Can finalize: instance completed + has WIP productions inst_status = instance.status.value if hasattr(instance.status, "value") else instance.status + # Partials (_dockbar, _op_card, _rail) read inst_status from context — + # only detail.html re-derives it with {% set %}. + context["inst_status"] = inst_status has_wip = any( (p.status.value if hasattr(p.status, "value") else p.status) == "wip" for p in productions ) context["can_finalize"] = inst_status == "completed" and has_wip - # Linked issues + # Linked issues — undispositioned holds sort first (rail ISSUES section) linked_issues = ( db.query(Issue) .filter( @@ -2290,24 +2321,49 @@ def _pick_default_order() -> int | None: ) .all() ) + disp_rank = {"undispositioned": 0, "open": 1, "dispositioned": 2, "closed": 3} + linked_issues.sort(key=lambda i: (not i.is_blocking, disp_rank.get(i.disp_state, 4), -i.id)) context["linked_issues"] = linked_issues - # Step-hold lookup: open NCs that put their step on hold, keyed by step_execution_id. - holding_ncs_by_step: dict[int, list[Issue]] = {} + # One authoritative answer per step execution for the COMPLETE and SKIP + # controls: the full fold the server enforces (held scope + sequence for + # COMPLETE; held scope + redline for SKIP), so a control never renders + # active where the server would 400 it (F4/F5). Templates render the + # controls from these — they do not re-derive gating. Keyed by + # step_execution_id; a present, non-empty list means the control renders + # inert (disabled) with the reason line beside it naming the blockers. + complete_gate_by_se: dict[int, list[exec_flow.Blocker]] = {} + skip_gate_by_se: dict[int, list[exec_flow.Blocker]] = {} + for se in instance.step_executions: + cg = exec_flow.complete_blockers(db, instance, se) + if cg: + complete_gate_by_se[se.id] = cg + sg = exec_flow.skip_blockers(db, instance, se) + if sg: + skip_gate_by_se[se.id] = sg + context["complete_gate_by_se"] = complete_gate_by_se + context["skip_gate_by_se"] = skip_gate_by_se + + # Resolved-issue trace per step execution id: a dispositioned/closed issue + # leaves a residual line on the step it was raised at — the hold clears, + # the record stays. + step_issue_history: dict[int, list[Issue]] = {} + for iss in linked_issues: + if iss.raised_step_id and iss.disp_state != "undispositioned": + step_issue_history.setdefault(iss.raised_step_id, []).append(iss) + context["step_issue_history"] = step_issue_history + + # Per-op aggregate of open NCs (op-level + any of its sub-steps) — the one + # redline-visibility predicate: + REDLINE renders wherever this is + # non-empty (step action rows and the dockbar overflow), and it populates + # the modal's NC dropdown. Keyed by op.order; ad-hoc (redline) ops are + # excluded below, so consumers never re-check is_ad_hoc. + open_ncs_by_step_exec: dict[int, list[Issue]] = {} for iss in linked_issues: iss_type = iss.issue_type.value if hasattr(iss.issue_type, "value") else iss.issue_type iss_status = iss.status.value if hasattr(iss.status, "value") else iss.status - if ( - iss_type == "non_conformance" - and iss.step_execution_id - and iss_status not in ("disposition_approved", "closed") - ): - holding_ncs_by_step.setdefault(iss.step_execution_id, []).append(iss) - context["step_holding_ncs"] = holding_ncs_by_step - - # Per-op aggregate of open NCs (op-level + any of its sub-steps). Used to - # decide when to show the "+ ADD REDLINE OP" button and to populate the - # modal's NC dropdown. Keyed by op.order. + if iss_type == "non_conformance" and iss.raised_step_id and iss_status != "closed": + open_ncs_by_step_exec.setdefault(iss.raised_step_id, []).append(iss) open_ncs_by_op_order: dict[int, list[Issue]] = {} for op_data in ops + contingency_ops: op_step = op_data["step"] @@ -2316,18 +2372,17 @@ def _pick_default_order() -> int | None: op_exec = op_step.get("execution") bucket: list[Issue] = [] if op_exec is not None: - bucket.extend(holding_ncs_by_step.get(op_exec.id, [])) + bucket.extend(open_ncs_by_step_exec.get(op_exec.id, [])) for sub in op_data.get("sub_steps", []): sub_exec = sub.get("execution") if sub_exec is not None: - bucket.extend(holding_ncs_by_step.get(sub_exec.id, [])) + bucket.extend(open_ncs_by_step_exec.get(sub_exec.id, [])) if bucket: open_ncs_by_op_order[op_step["order"]] = bucket context["op_open_ncs_by_order"] = open_ncs_by_op_order # Gating lookup: top-level ops whose prerequisite ops haven't reached # a terminal status yet. Keyed by op.order → list of blocking step_number_str. - terminal_step_statuses = {"completed", "signed_off", "skipped"} exec_by_order = {se.step_number: se for se in instance.step_executions} gated_ops_by_order: dict[int, list[str]] = {} version_steps_for_gating = version.content.get("steps", []) if version else [] @@ -2345,7 +2400,7 @@ def _pick_default_order() -> int | None: prereq_status = ( prereq.status.value if hasattr(prereq.status, "value") else prereq.status ) - if prereq_status not in terminal_step_statuses: + if prereq_status not in exec_flow.TERMINAL_STEP_STATUSES: blockers.append(prereq.step_number_str or str(dep_order)) if blockers: gated_ops_by_order[vs["order"]] = blockers @@ -2406,9 +2461,258 @@ def _pick_default_order() -> int | None: data_rows.sort(key=lambda r: (r["step_sort"], r["field"])) context["data_rows"] = data_rows + # ---- Document layer: cursors, holds, evidence counts, presence ---- + + cursors = exec_flow.instance_cursors(db, instance.id) + cursor_user_ids = {c.user_id for c in cursors} + cursor_users = ( + {u.id: u for u in db.query(User).filter(User.id.in_(cursor_user_ids)).all()} + if cursor_user_ids + else {} + ) + cursors_by_se: dict[int, list[dict]] = {} + for c in sorted(cursors, key=lambda c: c.focused_at): + cursors_by_se.setdefault(c.step_execution_id, []).append( + {"cursor": c, "user": cursor_users.get(c.user_id)} + ) + context["cursors_by_se"] = cursors_by_se + + current_user = context.get("current_user") + my_cursor = next((c for c in cursors if current_user and c.user_id == current_user.id), None) + context["my_cursor_order"] = ( + my_cursor.step_execution.step_number + if my_cursor is not None and my_cursor.step_execution is not None + else None + ) + + # Bound hold points (issue_step_block) — hold COMPLETE of the bound step. + bound_holds_by_se = exec_flow.bound_blocks_by_step(db, instance.id) + context["bound_holds_by_se"] = bound_holds_by_se + + # Undispositioned NC holds per step execution id — derived from containment. + holding_ncs_by_step = exec_flow.holding_ncs_by_step(db, instance.id) + context["step_holding_ncs"] = holding_ncs_by_step + + # Undispositioned scope per op order — the op-card HELD BY blockline. + # ONE derivation with the client updater (execdoc.js updateOpProgress): + # raised/containment holds AND bound "resolve by" holds both fold in — a + # bound hold on a child gates that child's COMPLETE, which holds the OP's + # completion (check_instance_completion._row_held folds blockers_for_start), + # so it belongs on the op header at SSR time too (F4). + op_holds_by_order: dict[int, list] = {} + for op_data in ops + contingency_ops: + op_exec = op_data["step"].get("execution") + bucket: list = [] + if op_exec is not None: + bucket.extend(holding_ncs_by_step.get(op_exec.id, [])) + bucket.extend(bound_holds_by_se.get(op_exec.id, [])) + for sub in op_data.get("sub_steps", []): + sub_exec = sub.get("execution") + if sub_exec is not None: + bucket.extend(holding_ncs_by_step.get(sub_exec.id, [])) + bucket.extend(bound_holds_by_se.get(sub_exec.id, [])) + seen_ids: set[int] = set() + unique = [b for b in bucket if not (b.id in seen_ids or seen_ids.add(b.id))] + if unique: + op_holds_by_order[op_data["step"]["order"]] = unique + context["op_holds_by_order"] = op_holds_by_order + + # strict_sequence display gating: sub-step N waits on its prior siblings. + # Display-only — the claim API gate in core/execution_flow is authoritative. + terminal = exec_flow.TERMINAL_STEP_STATUSES + seq_blockers_by_order: dict[int, str] = {} + for op_data in ops + contingency_ops: + op_vs = context["version_steps_map"].get(op_data["step"]["order"]) or {} + if not op_vs.get("strict_sequence"): + continue + for sub in op_data["sub_steps"]: + if sub["status"] != "pending": + continue + unmet = [ + s["step_number"] + for s in op_data["sub_steps"] + if s["order"] < sub["order"] and s["status"] not in terminal + ] + if unmet: + seq_blockers_by_order[sub["order"]] = "WAITING ON " + ", ".join(unmet) + context["seq_blockers_by_order"] = seq_blockers_by_order + + # Evidence counts (⎙n) per step execution id. + from opal.db.models.attachment import Attachment as _Att + + se_ids = [se.id for se in instance.step_executions] + attach_counts: dict[int, int] = {} + capture_attachments: dict[int, list] = {} + if se_ids: + for att in ( + db.query(_Att) + .filter(_Att.step_execution_id.in_(se_ids)) + .order_by(_Att.created_at.desc()) + .all() + ): + attach_counts[att.step_execution_id] = attach_counts.get(att.step_execution_id, 0) + 1 + capture_attachments.setdefault(att.step_execution_id, []).append(att) + context["attach_counts"] = attach_counts + context["capture_attachments"] = capture_attachments + + # Step note trail per step execution id — chronological, append-only. + context["step_notes_by_se"] = exec_flow.notes_by_step(db, se_ids) + + # Presence/progress snapshot for first paint; the page then polls /state. + context["exec_state"] = exec_flow.build_execution_state(db, instance) + + # Active users for the issue capture's optional assignee. + context["active_users"] = ( + db.query(User).filter(User.is_active.is_(True)).order_by(User.name.asc()).all() + ) + + return context + + +_BAR_ACTIONABLE = {"pending", "in_progress", "awaiting_signoff"} + + +def _set_bar_step(context: dict, step_order: int | None) -> None: + """Resolve the docked bar's step: the requested order, else the session + user's cursor, else the first actionable row of the document.""" + rows: list[tuple[dict, dict]] = [] + for op_data in context["ops"] + context["contingency_ops"]: + rows.append((op_data, op_data["step"])) + rows.extend((op_data, sub) for sub in op_data["sub_steps"]) + + target = None + if step_order is not None: + target = next(((od, r) for od, r in rows if r["order"] == step_order), None) + if target is None and context.get("my_cursor_order") is not None: + target = next(((od, r) for od, r in rows if r["order"] == context["my_cursor_order"]), None) + if target is None: + leaf_rows = [(od, r) for od, r in rows if not od["sub_steps"] or r is not od["step"]] + target = next(((od, r) for od, r in leaf_rows if r["status"] in _BAR_ACTIONABLE), None) or ( + leaf_rows[0] if leaf_rows else None + ) + + if target is None: + context["bar_step"] = None + return + + op_data, row = target + vs = context["version_steps_map"].get(row["order"], {}) + is_op = row is op_data["step"] + number = row["step_number"] + if "." not in number and not is_op: + number = f"{op_data['step']['step_number']}.{number}" + context["bar_step"] = { + "exec": row.get("execution"), + "order": row["order"], + "number": number, + "title": row["title"], + "status": row["status"], + "is_op": is_op, + "has_children": is_op and bool(op_data["sub_steps"]), + "schema": row.get("required_data_schema") or vs.get("required_data_schema"), + "caution": vs.get("caution"), + "op_order": op_data["step"]["order"], + "op_number": op_data["step"]["step_number"], + "op_is_ad_hoc": bool(op_data.get("is_ad_hoc")), + "op_open_ncs": (context.get("op_open_ncs_by_order") or {}).get( + op_data["step"]["order"], [] + ), + "step_kit": vs.get("step_kit") or [], + # Single per-row gate answer (F4): the COMPLETE/SKIP controls render + # from these, never re-derived in the template. Non-empty => control + # inert (disabled), reason line beside it. + "complete_blockers": ( + context.get("complete_gate_by_se", {}).get(row["execution"].id, []) + if row.get("execution") is not None + else [] + ), + "skip_blockers": ( + context.get("skip_gate_by_se", {}).get(row["execution"].id, []) + if row.get("execution") is not None + else [] + ), + } + + +@router.get("/executions/{instance_id}", response_class=HTMLResponse) +def executions_detail( + request: Request, + db: DbSession, + instance_id: int, + op: int | None = None, + tab: str = "document", +) -> HTMLResponse: + """Execution page — the multiplayer document.""" + instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() + if not instance: + return templates.TemplateResponse( + "errors/404.html", + {"request": request, "message": f"Execution {instance_id} not found"}, + status_code=404, + ) + + context = _execution_detail_context(request, db, instance) + _set_bar_step(context, None) + tab = _EXECUTION_TAB_ALIASES.get(tab, tab) + context["tab"] = tab if tab in _EXECUTION_TABS else "document" return templates.TemplateResponse("executions/detail.html", context) +@router.get("/executions/{instance_id}/dockbar", response_class=HTMLResponse) +def executions_dockbar( + request: Request, db: DbSession, instance_id: int, step: int | None = None +) -> HTMLResponse: + """Docked bar partial for the focused step — refetched as focus moves.""" + instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() + if not instance: + return HTMLResponse("", status_code=404) + context = _execution_detail_context(request, db, instance) + _set_bar_step(context, step) + return templates.TemplateResponse("executions/_dockbar.html", context) + + +@router.get("/executions/{instance_id}/rail", response_class=HTMLResponse) +def executions_rail(request: Request, db: DbSession, instance_id: int) -> HTMLResponse: + """Rail partial (issues/holds, attachments, reference docs).""" + instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() + if not instance: + return HTMLResponse("", status_code=404) + context = _execution_detail_context(request, db, instance) + return templates.TemplateResponse("executions/_rail.html", context) + + +@router.get("/executions/{instance_id}/step-row/{step_order}", response_class=HTMLResponse) +def executions_step_row( + request: Request, db: DbSession, instance_id: int, step_order: int +) -> HTMLResponse: + """One rendered step row — swapped in place when a step changes remotely + (spatial stability: the row updates, the document never reflows).""" + instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() + if not instance: + return HTMLResponse("", status_code=404) + context = _execution_detail_context(request, db, instance) + + for op_data in context["ops"] + context["contingency_ops"]: + if op_data["step"]["order"] == step_order and not op_data["sub_steps"]: + context["op_data"] = op_data + context["step"] = op_data["step"] + context["row_is_op"] = True + break + for sub in op_data["sub_steps"]: + if sub["order"] == step_order: + context["op_data"] = op_data + context["step"] = sub + context["row_is_op"] = False + break + else: + continue + break + else: + return HTMLResponse("", status_code=404) + + return templates.TemplateResponse("executions/_step_row.html", context) + + @router.get("/executions/{instance_id}/report", response_class=HTMLResponse) def executions_report(request: Request, db: DbSession, instance_id: int) -> HTMLResponse: """Standalone, printable build report for a completed work order.""" @@ -2615,22 +2919,31 @@ def _se_status(se) -> str: @router.get("/issues", response_class=HTMLResponse) -def issues_list(request: Request, db: DbSession) -> HTMLResponse: - """Issues list page.""" +def issues_list(request: Request, db: DbSession, state: str | None = Query(None)) -> HTMLResponse: + """Issues list page. `?state=` deep-links a STATE filter preselection.""" context = get_base_context(request, db, "Issues - OPAL") context["types"] = [t.value for t in IssueType] - context["statuses"] = [s.value for s in IssueStatus] + context["states"] = ["open", "undispositioned", "dispositioned", "closed"] context["priorities"] = [p.value for p in IssuePriority] + context["state_selected"] = state if state in context["states"] else None return templates.TemplateResponse("issues/list.html", context) +ISSUE_TYPE_ABBREV = { + "non_conformance": "NC", + "bug": "BUG", + "task": "TASK", + "improvement": "IMPR", +} + + @router.get("/issues/table", response_class=HTMLResponse) def issues_table( request: Request, db: DbSession, search: str | None = Query(None), issue_type: str | None = Query(None), - status: str | None = Query(None), + state: str | None = Query(None), priority: str | None = Query(None), page: int = Query(1, ge=1), ) -> HTMLResponse: @@ -2642,28 +2955,56 @@ def issues_table( query = query.filter(Issue.title.ilike(search_term)) if issue_type: query = query.filter(Issue.issue_type == issue_type) - if status: - query = query.filter(Issue.status == status) if priority: query = query.filter(Issue.priority == priority) - issues, pagination = paginate_query(request, query.order_by(Issue.id.desc()), page, colspan=6) + # Four-value STATE filter, matching what the column renders: open = + # advisory-open; undispositioned / dispositioned = containment-bearing only. + signed = (Issue.disposition_type.isnot(None)) & (Issue.dispositioned_at.isnot(None)) + bearing = Issue.containment != Containment.ADVISORY + if state == "closed": + query = query.filter(Issue.status == IssueStatus.CLOSED) + elif state == "open": + query = query.filter( + Issue.status != IssueStatus.CLOSED, Issue.containment == Containment.ADVISORY + ) + elif state == "dispositioned": + query = query.filter(Issue.status != IssueStatus.CLOSED, bearing, signed) + elif state == "undispositioned": + query = query.filter(Issue.status != IssueStatus.CLOSED, bearing, ~signed) + + # Undispositioned-with-containment sorts first — the only warning-weight + # on the page. + blocking = (Issue.status != IssueStatus.CLOSED) & bearing & ~signed + query = query.order_by(case((blocking, 0), else_=1), Issue.id.desc()) + + issues, pagination = paginate_query(request, query, page, colspan=6) def get_val(obj, attr): val = getattr(obj, attr) return val.value if hasattr(val, "value") else val + def age(dt: datetime) -> str: + """Dense relative age for index rows; full ISO 8601 in the tooltip.""" + aware = dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt + delta = datetime.now(UTC) - aware + if delta.days >= 1: + return f"{delta.days}d" + hours = delta.seconds // 3600 + if hours: + return f"{hours}h" + return f"{max(delta.seconds // 60, 0)}m" + issues_data = [ { "id": i.id, "issue_number": i.issue_number, "title": i.title, - "issue_type": get_val(i, "issue_type"), - "status": get_val(i, "status"), + "issue_type": ISSUE_TYPE_ABBREV.get(get_val(i, "issue_type"), get_val(i, "issue_type")), + "disp_state": i.disp_state, "priority": get_val(i, "priority"), "created_at": i.created_at, - "procedure_id": i.procedure_id, - "procedure_instance_id": i.procedure_instance_id, + "age": age(i.created_at), } for i in issues ] @@ -2675,13 +3016,24 @@ def get_val(obj, attr): @router.get("/issues/new", response_class=HTMLResponse) -def issues_new(request: Request, db: DbSession) -> HTMLResponse: - """New issue form page.""" +def issues_new( + request: Request, + db: DbSession, + procedure_instance_id: int | None = Query(None), + execution: int | None = Query(None), +) -> HTMLResponse: + """New issue form page. Context pre-fill: ?execution= (or the older + ?procedure_instance_id=) pre-selects the work-order link.""" context = get_base_context(request, db, "New Issue - OPAL") context["types"] = [t.value for t in IssueType] context["priorities"] = [p.value for p in IssuePriority] + context["containments"] = [c.value for c in Containment] + context["procedure_instance_id"] = ( + procedure_instance_id if procedure_instance_id is not None else execution + ) - # Get procedures and users for linking (parts use the search typeahead) + # Get procedures, executions and users for linking (parts use the search + # typeahead). The EXECUTION select mirrors the issue page's LINKS panel. procedures = ( db.query(MasterProcedure) .filter(MasterProcedure.deleted_at.is_(None)) @@ -2692,14 +3044,18 @@ def issues_new(request: Request, db: DbSession) -> HTMLResponse: users = db.query(User).filter(User.is_active == True).order_by(User.name).all() # noqa: E712 context["procedures"] = procedures + context["instances"] = db.query(ProcedureInstance).order_by(ProcedureInstance.id.desc()).all() context["users"] = users return templates.TemplateResponse("issues/new.html", context) @router.get("/issues/{issue_id}", response_class=HTMLResponse) -def issues_detail(request: Request, db: DbSession, issue_id: int) -> HTMLResponse: - """Issue detail page.""" +def issues_detail( + request: Request, db: DbSession, issue_id: int, edit: bool = Query(False) +) -> HTMLResponse: + """Issue detail page. Opens read-only; ?edit=1 renders the in-place + editors, DONE returns to view.""" issue = db.query(Issue).filter(Issue.id == issue_id, Issue.deleted_at.is_(None)).first() if not issue: return templates.TemplateResponse( @@ -2708,11 +3064,12 @@ def issues_detail(request: Request, db: DbSession, issue_id: int) -> HTMLRespons status_code=404, ) - context = get_base_context(request, db, f"Issue {issue_id} - OPAL") + context = get_base_context(request, db, f"Issue {issue.issue_number} - OPAL") context["issue"] = issue + context["editing"] = edit context["types"] = [t.value for t in IssueType] - context["statuses"] = [s.value for s in IssueStatus] context["priorities"] = [p.value for p in IssuePriority] + context["containments"] = [c.value for c in Containment] from opal.db.models.attachment import Attachment from opal.db.models.issue import DispositionType @@ -2732,6 +3089,37 @@ def issues_detail(request: Request, db: DbSession, issue_id: int) -> HTMLRespons context["attachments"] = attachments context["users"] = users context["disposition_types"] = [d.value for d in DispositionType] + # HOLDING — the consequence readout: what this issue is stopping. + context["holding"] = holding_readout(db, issue) + + # Spawn-redline target: the op (level-0 order) hosting the raised step. + redline_op_order = None + if issue.raised_step is not None and issue.procedure_instance_id is not None: + raised = issue.raised_step + redline_op_order = raised.step_number if raised.level == 0 else raised.parent_step_order + context["redline_op_order"] = redline_op_order + + # RAISED AT is scope-named, never a bare number ("OP 4" / "4.1"). + context["raised_at_label"] = scope_label(issue.raised_step) if issue.raised_step else None + + # Linking goes both directions: the LINKS panel can attach a work order + # and a containment boundary step from the issue side. + from opal.db.models.execution import StepExecution + + context["instances"] = db.query(ProcedureInstance).order_by(ProcedureInstance.id.desc()).all() + instance_steps = [] + if issue.procedure_instance_id is not None: + steps = ( + db.query(StepExecution) + .filter(StepExecution.instance_id == issue.procedure_instance_id) + .order_by(StepExecution.step_number) + .all() + ) + instance_steps = [{"id": s.id, "label": scope_label(s), "title": s.title} for s in steps] + context["instance_steps"] = instance_steps + context["containment_step_label"] = ( + scope_label(issue.containment_step) if issue.containment_step else None + ) return templates.TemplateResponse("issues/detail.html", context) @@ -2798,7 +3186,6 @@ def risks_table( rows = [ { "risk": r, - "badge": DISPOSITION_BADGES.get(r.disposition, "draft"), "reviewed_age": _relative_age(r.last_reviewed_at) if r.last_reviewed_at else "—", "reviewed_iso": r.last_reviewed_at.strftime("%Y-%m-%dT%H:%M:%SZ") if r.last_reviewed_at @@ -2869,8 +3256,11 @@ def risks_new(request: Request, db: DbSession) -> HTMLResponse: @router.get("/risks/{risk_id}", response_class=HTMLResponse) -def risks_detail(request: Request, db: DbSession, risk_id: int) -> HTMLResponse: - """Risk detail page — the generated statement is the masthead.""" +def risks_detail( + request: Request, db: DbSession, risk_id: int, edit: bool = Query(False) +) -> HTMLResponse: + """Risk detail page — the generated statement is the masthead. Opens + read-only; ?edit=1 renders the in-place editors, DONE returns to view.""" from opal.risks.lint import lint_risk_row from opal.risks.readiness import readiness from opal.web.lint_markup import statement_lint_html @@ -2886,15 +3276,14 @@ def risks_detail(request: Request, db: DbSession, risk_id: int) -> HTMLResponse: findings = lint_risk_row(risk) linked_issue_ids = [link.issue_id for link in risk.issue_links] - parts = ( - db.query(Part).filter(Part.deleted_at.is_(None)).order_by(Part.name).limit(200).all() - ) + parts = db.query(Part).filter(Part.deleted_at.is_(None)).order_by(Part.name).limit(200).all() # The dropdown is capped; the set asset must still render as selected. if risk.asset_part is not None and risk.asset_part not in parts: parts.append(risk.asset_part) context = get_base_context(request, db, f"{risk.risk_number} - OPAL") context["risk"] = risk + context["editing"] = edit context["badge"] = DISPOSITION_BADGES.get(risk.disposition, "draft") context["dispositions"] = [d.value for d in RiskDisposition] context["roles"] = [r.value for r in RiskIssueRole] @@ -2916,7 +3305,9 @@ def risks_detail(request: Request, db: DbSession, risk_id: int) -> HTMLResponse: @router.get("/risks/{risk_id}/acceptance-panel", response_class=HTMLResponse) -def risks_acceptance_panel(request: Request, db: DbSession, risk_id: int) -> HTMLResponse: +def risks_acceptance_panel( + request: Request, db: DbSession, risk_id: int, edit: bool = Query(False) +) -> HTMLResponse: """Acceptance panel partial — re-fetched after field saves.""" from opal.risks.readiness import readiness @@ -2928,6 +3319,7 @@ def risks_acceptance_panel(request: Request, db: DbSession, risk_id: int) -> HTM { "request": request, "risk": risk, + "editing": edit, "readiness": readiness(db, risk), "current_user": _get_current_user(request, db), }, diff --git a/src/opal/web/static/css/execdoc.css b/src/opal/web/static/css/execdoc.css new file mode 100644 index 0000000..405f0a9 --- /dev/null +++ b/src/opal/web/static/css/execdoc.css @@ -0,0 +1,743 @@ +/* OPAL Execution Document — header, document, rail, docked bar. + Loaded only on the execution page. All colors via theme variables. */ + +/* ============ Header ============ */ + +.exec-head { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs) 0; + border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; +} + +.exec-head-wo { + font-weight: 700; + font-size: 1rem; +} + +.exec-head-proc { + color: var(--text-primary); + text-decoration: none; +} + +.exec-head-proc:hover { color: var(--accent-orange); } + +.exec-head-progress { color: var(--text-secondary); } + +.exec-progressbar { + display: inline-block; + width: 90px; + height: 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + vertical-align: middle; +} + +.exec-progressbar-fill { + display: block; + height: 100%; + background: var(--accent-green); + transition: width 0.3s ease; +} + +.exec-head-spacer { flex: 1; } + +.exec-head-actions { + display: flex; + gap: var(--space-xs); + align-items: center; +} + +/* Presence: "N ONLINE" counter; the roster lives in the left rail and in + the counter's popover (the only roster when the rails are hidden). */ +.exec-online { + font-size: 0.75rem; + padding: 1px var(--space-xs); + border: 1px solid var(--accent-orange); + color: var(--accent-orange); + cursor: pointer; + white-space: nowrap; +} + +.exec-roster-pop { + position: absolute; + left: var(--space-md); + top: 5.5rem; + z-index: var(--z-dropdown); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + padding: var(--space-sm) var(--space-md); + min-width: 220px; + font-size: 0.8rem; +} + +.roster-row { + padding: 1px 0; + cursor: pointer; + white-space: nowrap; + color: var(--accent-orange); +} + +.roster-row.is-self { font-weight: 700; } +.roster-row.is-stale { color: var(--text-muted); } + +/* Meta readout popover (the META tab's facts, compacted) */ +.exec-meta-popover { + position: absolute; + right: var(--space-md); + top: 5.5rem; + z-index: var(--z-dropdown); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + padding: var(--space-sm) var(--space-md); + min-width: 340px; + font-size: 0.8rem; +} + +.exec-meta-popover .ro-row { + display: flex; + gap: var(--space-md); + padding: 1px 0; +} + +.exec-meta-popover .ro-label { + color: var(--text-secondary); + min-width: 140px; +} + +/* ============ Document layout ============ + The document is a centered readable column (~860px); the rails FLOAT in + the side gutters and the page scrolls as one surface — no inner + scrollbars. Below 1280px the rails hide: content is king. */ + +.exec-doc-layout { + display: flex; + gap: var(--space-md); + align-items: flex-start; + margin-bottom: var(--space-sm); +} + +.exec-doc { + flex: 1; + min-width: 0; + max-width: 860px; + margin: 0 auto; +} + +.exec-rail, +.exec-rail-left { + width: 280px; + flex-shrink: 0; + position: sticky; + top: var(--space-sm); + max-height: calc(100vh - 9rem); + overflow-y: auto; + border: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.exec-rail-left { + width: 230px; + padding: var(--space-sm); +} + +@media (max-width: 1279px) { + .exec-rail-left { display: none; } +} + +/* Left rail: roster + OP minimap */ +.rail-roster .roster-row { font-size: 0.75rem; } + +.minimap-row { + display: flex; + gap: var(--space-xs); + align-items: baseline; + font-size: 0.7rem; + padding: 1px 0; + cursor: pointer; + color: var(--text-secondary); +} + +.minimap-row:hover { color: var(--text-primary); } +.minimap-num { min-width: 1.5em; text-align: right; } +.minimap-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.minimap-prog { color: var(--text-muted); } +.minimap-prog.is-done { color: var(--accent-green); } + +.exec-doc-divider { + color: var(--text-muted); + font-size: 0.75rem; + letter-spacing: 0.1em; + border-top: 1px solid var(--border-color); + margin: var(--space-md) 0 var(--space-sm); + padding-top: var(--space-xs); +} + +/* ============ OP cards ============ */ + +.op-card { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + margin-bottom: calc(var(--space-sm) * 2); +} + +.op-card.is-redline { border-left: 3px solid var(--accent-red); } +.op-card.is-contingency { border-left: 3px solid var(--accent-yellow); } + +.op-card-head { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs) var(--space-sm); + cursor: pointer; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; +} + +.op-card.is-collapsed .op-card-head { border-bottom: none; } +.op-card.is-done .op-card-head { background: transparent; } +.op-card.is-done .op-card-title, +.op-card.is-done .op-card-num { color: var(--text-muted); } + +.op-card-caret { color: var(--text-muted); width: 1em; } +.op-card-num { font-weight: 700; } +.op-card-title { font-weight: 600; text-transform: uppercase; } +.op-card-spacer { flex: 1; } +.op-card-progress { color: var(--text-secondary); } +.op-card-progress.is-done { color: var(--accent-green); } +.op-caution-chip { color: var(--accent-red); border-color: var(--accent-red); } +.op-card-blockline a { color: inherit; } + +.op-card-body { padding: var(--space-xs) var(--space-sm) var(--space-sm); } +.op-instructions { + color: var(--text-primary); + font-size: 1rem; + margin-bottom: var(--space-xs); +} + +.exec-redline-banner { + display: block; + font-size: 0.7rem; + letter-spacing: 0.08em; + color: var(--accent-red); + padding: 1px var(--space-sm); + text-decoration: none; +} + +/* ============ Step rows ============ */ + +.doc-step { + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent); +} + +.doc-step:last-child { border-bottom: none; } + +.doc-step-line { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs) var(--space-xs); + cursor: pointer; + min-height: 2rem; +} + +.doc-step-line:hover { background: var(--bg-tertiary); } + +.doc-step-num { + color: var(--text-secondary); + min-width: 3em; + text-align: right; +} + +.doc-step-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1rem; + color: var(--text-primary); +} + +.doc-step.is-done .doc-step-title, +.doc-step.is-skipped .doc-step-title { + color: var(--text-muted); + text-decoration: line-through; +} + +.doc-step.is-focused .doc-step-line { + border-left: 2px solid var(--accent-orange); + background: color-mix(in srgb, var(--accent-orange) 7%, transparent); +} + +/* Presence cursors render in the RIGHT GUTTER, outside the OP card border — + inside the box is procedure, outside the box is people. */ +.doc-step-line { position: relative; } + +.step-cursors { + display: inline-flex; + gap: 2px; +} + +.cursor-chip { + font-size: 0.7rem; + padding: 0 4px; + border: 1px solid var(--accent-orange); + color: var(--accent-orange); + white-space: nowrap; + background: var(--bg-primary); +} + +.cursor-chip.is-self { background: var(--accent-orange); color: var(--bg-primary); } +.cursor-chip.is-stale { border-color: var(--border-light); color: var(--text-muted); } + +@media (min-width: 1280px) { + .step-cursors { + position: absolute; + left: 100%; + margin-left: calc(var(--space-sm) + 8px); + top: 50%; + transform: translateY(-50%); + } +} + +.doc-step.pulse, .rail-attach-group.pulse { + animation: execdoc-pulse 1.5s ease; +} + +@keyframes execdoc-pulse { + 0% { background: color-mix(in srgb, var(--accent-orange) 35%, transparent); } + 100% { background: transparent; } +} + +.role-chip { + font-size: 0.7rem; + padding: 0 var(--space-xs); + border: 1px solid var(--border-light); + color: var(--text-secondary); + white-space: nowrap; +} + +.doc-ind { display: inline-flex; gap: var(--space-xs); color: var(--text-secondary); font-size: 0.75rem; } +.doc-ind .ind { cursor: pointer; } + +.doc-step-presence { + min-width: 7em; + text-align: right; + font-size: 0.8rem; + white-space: nowrap; +} + +.pres-done { color: var(--text-muted); } +.pres-skip { color: var(--accent-yellow); } +.pres-hold { color: var(--accent-yellow); } +.pres-hold a { color: var(--accent-yellow); } +.pres-gated { font-size: 0.7rem; color: var(--text-muted); } + +.doc-step-body { + padding: var(--space-xs) var(--space-sm) var(--space-sm) calc(3em + var(--space-lg)); + border-top: 1px dashed color-mix(in srgb, var(--border-color) 50%, transparent); + background: var(--bg-primary); +} + +/* Authored reference imagery (instruction content — the inline exception) */ +.step-image-strip { + display: flex; + gap: var(--space-sm); + flex-wrap: wrap; + margin: var(--space-xs) 0; +} + +.step-image { margin: 0; } + +.step-image img { + max-height: 110px; + max-width: 160px; + border: 1px solid var(--border-color); + cursor: zoom-in; + display: block; +} + +.step-image figcaption { + font-size: 0.7rem; + color: var(--text-secondary); + max-width: 160px; +} + +/* Read-only data readout */ +.step-data-readout { margin: var(--space-xs) 0; } + +.ro-row { + display: flex; + gap: var(--space-md); + align-items: baseline; + padding: 1px 0; +} + +.ro-label { + color: var(--text-secondary); + font-size: 0.75rem; + min-width: 160px; +} + +.ro-notes { + white-space: pre-wrap; + color: var(--text-secondary); + font-size: 0.8rem; +} + +/* The running notes log — deliberately its own block, set off from the + data-capture field stack by a hairline rule so it never reads as another + required field: NOTES label, timestamped trail, append input underneath. */ +.step-notes-block { + display: flex; + gap: var(--space-md); + align-items: baseline; + margin-top: var(--space-sm); + padding-top: var(--space-xs); + border-top: 1px solid var(--border-color); +} + +.step-notes-label { + color: var(--text-secondary); + font-size: 0.75rem; + min-width: 160px; +} + +.step-notes-body { flex: 1; min-width: 0; } + +.note-line { + padding: 1px 0; + font-size: 0.8rem; + white-space: pre-wrap; +} + +.step-note-add { + display: block; + width: 100%; + max-width: 480px; + margin-top: 2px; + padding: 2px var(--space-xs); + font-size: 0.8rem; + color: var(--text-primary); + background: transparent; + border: 1px dashed var(--border-color); +} + +.step-note-add:focus { + outline: none; + border-style: solid; + border-color: var(--text-secondary); +} + +.step-kit-panel { margin: var(--space-xs) 0; } + +/* Expanded-step control surface: fields, notes, actions. */ +.step-controls { margin-top: var(--space-sm); } + +.step-controls-fields { + display: flex; + gap: var(--space-md); + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: var(--space-xs); +} + +.step-actions { + display: flex; + gap: var(--space-xs); + justify-content: flex-end; +} +/* Gated control (F4/F5): the disabled button with its reason beside it — + inert + reason, never an absent control. */ +.gated-control { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.75rem; +} +.step-kit-actions { display: flex; justify-content: flex-end; margin-top: var(--space-xs); } +.step-kit-consumed { margin-top: var(--space-xs); font-size: 0.75rem; } + + +/* ============ Rail ============ */ + +.exec-rail-content { padding: var(--space-sm); } + +.rail-section { margin-bottom: var(--space-md); } + +.rail-head { + font-size: 0.7rem; + letter-spacing: 0.1em; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + padding-bottom: 2px; + margin-bottom: var(--space-xs); +} + +.rail-table { font-size: 0.75rem; } +.rail-table td { padding: 2px var(--space-xs); } +.rail-disp { font-size: 0.65rem; white-space: nowrap; } +.rail-disp-undispositioned { color: var(--accent-red); } +.rail-issue-title { overflow: hidden; text-overflow: ellipsis; max-width: 140px; white-space: nowrap; } + +.rail-attach-group { margin-bottom: var(--space-sm); } + +.rail-attach-step { + font-size: 0.7rem; + color: var(--accent-orange); + margin-bottom: 2px; +} + +.rail-attach-item { margin-bottom: var(--space-xs); } + +.rail-thumb { + max-width: 100%; + max-height: 140px; + border: 1px solid var(--border-color); + cursor: zoom-in; + display: block; +} + +.rail-attach-meta { font-size: 0.65rem; color: var(--text-muted); } +.rail-attach-note { font-size: 0.75rem; color: var(--text-secondary); } +.rail-file { font-size: 0.75rem; word-break: break-all; } +.rail-attach-add { margin-top: var(--space-xs); } + +/* ============ Docked bar (the focused step's control surface) ============ */ + +#dockbar { + position: fixed; + bottom: calc(var(--opal-footer-h, 0px) + var(--space-sm)); /* clear of the footer */ + left: 50%; + transform: translateX(-50%); + width: min(900px, 100vw); + z-index: var(--z-dropdown); +} + +body.has-dockbar .main { padding-bottom: 5.5rem; } +body.dockbar-off #dockbar { display: none; } +body.dockbar-off.has-dockbar .main { padding-bottom: var(--space-md); } + +.dockbar { + display: flex; + align-items: flex-end; + gap: var(--space-md); + border: 1px solid var(--accent-orange); + border-bottom-width: 2px; + background: var(--bg-secondary); + padding: var(--space-xs) var(--space-md); + flex-wrap: wrap; +} + +.dockbar-id { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 180px; + cursor: pointer; +} + +.dockbar-num { + font-weight: 700; + font-size: 1.05rem; + color: var(--accent-orange); +} + +.dockbar-title { + font-weight: 600; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dockbar-caution { flex-basis: 100%; margin: 0; } + +.dockbar-fields { + display: flex; + gap: var(--space-md); + align-items: flex-end; + flex: 1; + flex-wrap: wrap; +} + +.bar-field { display: flex; flex-direction: column; gap: 2px; min-width: 120px; } +.bar-field .form-input, .bar-field .form-select { padding: 2px var(--space-xs); } +.bar-field-label { font-size: 0.65rem; color: var(--text-secondary); } +.bar-check { display: flex; align-items: center; gap: var(--space-xs); font-size: 0.75rem; } + +.dockbar-actions { + display: flex; + gap: var(--space-xs); + align-items: center; + margin-left: auto; +} + +.dockbar-overflow { position: relative; } + +.dockbar-menu { + position: absolute; + bottom: calc(100% + 4px); + right: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + padding: var(--space-xs); + z-index: var(--z-dropdown); + min-width: 150px; +} + +/* ============ Modals / lightbox / toasts ============ */ + +.execdoc-modal { + position: fixed; + inset: 0; + background: var(--modal-backdrop); + z-index: var(--z-modal); +} + +.execdoc-modal-box { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 560px; + max-width: 95vw; + max-height: 90vh; + overflow-y: auto; +} + +.execdoc-modal-sm { width: 440px; } + +.execdoc-modal-actions { + display: flex; + gap: var(--space-md); + justify-content: flex-end; + margin-top: var(--space-md); +} + +.anomaly-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-md); +} + +.skip-target { margin-bottom: var(--space-md); } + +.redline-step-row { + border: 1px solid var(--border-color); + padding: var(--space-sm); + margin-bottom: var(--space-xs); +} + +.redline-step-head { + display: flex; + gap: var(--space-sm); + align-items: center; + margin-bottom: var(--space-xs); +} + +.redline-step-head .redline-step-title { flex: 1; } +.redline-step-row textarea { width: 100%; } + +.execdoc-lightbox { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.88); + z-index: var(--z-modal); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: zoom-out; + gap: var(--space-sm); +} + +.execdoc-lightbox img { + max-width: 92vw; + max-height: 86vh; + border: 1px solid var(--border-color); +} + +#lightbox-caption { color: var(--text-secondary); font-size: 0.8rem; } + +#execdoc-toasts { + position: fixed; + bottom: 4.5rem; + right: var(--space-md); + z-index: var(--z-modal); + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.execdoc-toast { + background: var(--bg-secondary); + border: 1px solid var(--accent-orange); + color: var(--text-primary); + padding: var(--space-xs) var(--space-md); + font-size: 0.8rem; +} + +.execdoc-toast.is-error { border-color: var(--accent-red); color: var(--accent-red); } +.execdoc-toast.fade-out { opacity: 0; transition: opacity 0.3s; } +.execdoc-toast a { color: var(--accent-orange); } + +/* Rail drawer toggle (tablet only) */ +.rail-toggle { display: none; } + +/* ============ Tablet ============ */ + +@media (max-width: 1279px) { + .exec-rail { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(320px, 85vw); + max-height: none; + transform: translateX(100%); + transition: transform 0.2s ease; + z-index: var(--z-dropdown); + border-left: 1px solid var(--border-color); + padding-top: var(--space-md); + } + + .exec-doc-layout.rail-open .exec-rail { transform: translateX(0); } + + .rail-toggle { + display: block; + position: fixed; + right: var(--space-sm); + top: 7rem; + z-index: calc(var(--z-dropdown) + 1); + } + + .dockbar { gap: var(--space-sm); } + .dockbar-fields { flex-basis: 100%; order: 2; } + .bar-field { flex: 1; } + .bar-field .form-input, .bar-field .form-select, .bar-field textarea { + font-size: 1rem; + padding: var(--space-xs) var(--space-sm); + } + .dockbar-actions { order: 3; flex-basis: 100%; justify-content: flex-end; } + .dockbar-actions .btn { padding: var(--space-sm) var(--space-md); } + + .exec-meta-popover { right: var(--space-xs); min-width: 280px; } +} + +/* Residual issue trace on a step: the hold clears, the record stays. */ +.step-issue-history { font-size: 0.7rem; margin-bottom: var(--space-sm); } +.step-issue-history a { color: var(--text-muted); text-decoration: underline; } diff --git a/src/opal/web/static/css/main.css b/src/opal/web/static/css/main.css index 23fca28..c312b9a 100644 --- a/src/opal/web/static/css/main.css +++ b/src/opal/web/static/css/main.css @@ -30,7 +30,7 @@ --text-primary: #e0e0e0; --text-secondary: #888; - --text-muted: #666; + --text-muted: #7a7a7a; /* AA floor: 4.61:1 on bg-primary at small sizes */ --accent-blue: #4a9eff; --accent-green: #4ade80; @@ -78,6 +78,12 @@ padding: 0; } +/* The hidden attribute must win over any class that sets display + (author display rules override the UA's [hidden] default). */ +[hidden] { + display: none !important; +} + html { font-size: 14px; } @@ -438,6 +444,16 @@ select option { color: var(--bg-primary); } +/* Disabled buttons gray out regardless of variant — the gate must be + visible, not just functional. */ +.btn:disabled, +.btn:disabled:hover { + border-color: var(--border-color); + background: var(--bg-tertiary); + color: var(--text-muted); + cursor: not-allowed; +} + .btn-danger { border-color: var(--accent-red); color: var(--accent-red); @@ -2227,9 +2243,13 @@ a.user-menu-link.active { /* Flag + allocation chips */ .flag-chip { - font-size: 0.65rem; - padding: 0 4px; - border: 1px solid var(--border-color); + /* Same metrics as .status so DRAFT and TBD/TBR chips sit flush. */ + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; + padding: 2px 6px; + border: 1px solid currentColor; white-space: nowrap; } @@ -2412,6 +2432,17 @@ a.user-menu-link.active { border: 1px solid var(--accent-green); } +/* Slim signature confirm — one consequence sentence, CONFIRM/ABORT inline, + no box (issue disposition, risk acceptance). The boxed .baseline-confirm + register stays with the requirements baseline. */ +.sign-confirm { + margin-top: var(--space-sm); + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + .baseline-signature { margin-bottom: var(--space-sm); } @@ -2553,66 +2584,36 @@ details.disclosure > summary { details.disclosure > summary::-webkit-details-marker { display: none; } details.disclosure[open] > summary { color: var(--text-secondary); } -/* The ledger — part page. An empty section is one line, never a box. - Grouping is carried by column placement: design-side left, world-side - right. Width is an information budget — never one stretched sparse - column. */ -.part-actions { - display: flex; - align-items: center; - gap: var(--space-sm); - flex-wrap: wrap; - margin: var(--space-md) 0; -} - -.part-actions .part-action-right { - margin-left: auto; -} - -.empty-line { - color: var(--text-muted); - font-size: 0.8rem; - padding: var(--space-xs) 2px; - border-bottom: 1px solid var(--border-color); -} - -@supports (color: color-mix(in srgb, red 50%, transparent)) { - .empty-line { - border-bottom-color: color-mix(in srgb, var(--border-color) 50%, transparent); - } -} - -.empty-line a { - color: var(--text-secondary); - text-decoration: none; +/* The part page — data renders in tables: detail rows for facts, headed + tables for collections. An empty section is one line, never a box; a + populated section promotes to the panel grammar at ledger pitch. */ +/* Identity readout table — every meta fact, always rendered */ +.part-identity { + margin: var(--space-md) 0 0; } -.part-description { - margin: var(--space-md) 0; - max-width: 80ch; +.part-identity th { + width: 130px; } -/* Dense mono readout — labels are nouns, values are facts */ -.part-readout { +/* Zone captions — two nouns, no boxes, no counts */ +.zone-caption { font-family: var(--font-mono); font-size: 0.75rem; - color: var(--text-primary); - margin: var(--space-sm) 0 var(--space-md); - line-height: 1.7; -} - -.part-readout .ro-label { + letter-spacing: 0.1em; color: var(--text-muted); + border-top: 2px solid var(--border-color); + padding-top: var(--space-xs); + margin: var(--space-md) 0 var(--space-sm); } -/* Two-column part page — each column a bounded information budget; - row actions sit at the column edge, adjacent to content */ +/* Two-column part page — identity rail bounded, collection tables get + the viewport width their columns spend */ .part-layout { display: grid; - grid-template-columns: repeat(2, minmax(0, 640px)); + grid-template-columns: minmax(420px, 560px) minmax(0, 1fr); gap: var(--space-lg); align-items: start; - justify-content: center; } @media (max-width: 1000px) { @@ -2621,80 +2622,114 @@ details.disclosure[open] > summary { color: var(--text-secondary); } } } -.ledger-row { - display: flex; - align-items: baseline; - gap: var(--space-sm); - padding: 5px 2px; - border-bottom: 1px solid var(--border-color); +/* The empty-state rule — an empty relevant section is one line at half + strength: "LABEL — none · + ADD". Irrelevant empty sections are absent; + data always renders. The add affordance never renders muted. */ +.empty-line { font-family: var(--font-mono); font-size: 0.8rem; + color: var(--text-muted); + padding: 5px 2px; + border-bottom: 1px solid var(--border-color); } -.ledger-row-empty { - padding: 2px 2px; -} - -.ledger-row-empty .ledger-label, -.ledger-row-empty .ledger-value, -.ledger-row-empty .ledger-add { - color: var(--text-muted); +@supports (color: color-mix(in srgb, red 50%, transparent)) { + .empty-line { + border-bottom-color: color-mix(in srgb, var(--border-color) 50%, transparent); + } } -.ledger-row .ledger-label { +.empty-line .ledger-label { color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; } -.ledger-row .ledger-value { - margin-left: auto; - color: var(--text-primary); -} - .ledger-flag { color: var(--status-warn); } -.ledger-row .ledger-add { +.ledger-add { color: var(--text-secondary); text-decoration: none; + padding: 2px 4px; } -.ledger-detail { - font-family: var(--font-mono); - font-size: 0.8rem; +.ledger-add:hover { color: var(--text-primary); - padding: var(--space-xs) 0 var(--space-xs) var(--space-md); - border-bottom: 1px solid var(--border-color); } -.ledger-detail-line { - display: flex; - align-items: baseline; - gap: var(--space-sm); - padding: 1px 0; +/* Populated section — the app's panel grammar at ledger pitch */ +.panel-ledger { + margin-bottom: var(--space-md); } -.ledger-detail-line .ldl-muted { - color: var(--text-muted); +.panel-ledger .panel-header { + padding: 4px var(--space-sm); + font-size: 0.8rem; } -.ledger-detail-line .ldl-actions { +.panel-ledger .header-count { margin-left: auto; - display: flex; - gap: var(--space-xs); + margin-right: var(--space-sm); + font-weight: 400; + color: var(--text-primary); } -.ledger-detail-line .ldl-actions a { - color: var(--text-muted); +.panel-ledger .header-count:last-child { + margin-right: 0; +} + +.panel-ledger .stock-figure { + font-size: 1.125rem; + font-weight: 600; +} + +.panel-ledger .data-table { + font-size: 0.8rem; +} + +.panel-ledger .data-table th, +.panel-ledger .data-table td { + padding: 5px 8px; +} + +.panel-ledger .data-table .row-actions { + white-space: nowrap; + text-align: right; +} + +.panel-ledger .data-table .row-actions a { + color: var(--text-secondary); text-decoration: none; + padding: 2px 6px; } -.ledger-detail-line .ldl-actions a:hover { +.panel-ledger .data-table .row-actions a:hover { color: var(--text-primary); } +.panel-ledger .data-table .row-actions a.act-remove:hover { + color: var(--accent-red); +} + +.panel-ledger .bom-tree { + font-family: var(--font-mono); + font-size: 0.8rem; + padding: var(--space-xs) var(--space-sm); + border-top: 1px solid var(--border-color); +} + +/* Inline add rows (assign requirement, receive stock) */ +.inline-add-row { + display: none; + gap: var(--space-sm); + align-items: center; + padding: var(--space-xs) 2px; + font-family: var(--font-mono); + font-size: 0.8rem; +} + /* Parts list — one line per row, pitch matched to the part-page ledger. Accent color budget: PN only; names render primary. */ .parts-list td, @@ -2703,15 +2738,25 @@ details.disclosure[open] > summary { color: var(--text-secondary); } white-space: nowrap; } -.parts-list .row-link-plain { +/* Accent color budget, globally: identifiers and actions only — row content + links render primary. */ +.row-link-plain { color: var(--text-primary); text-decoration: none; } -.parts-list .row-link-plain:hover { +.row-link-plain:hover { text-decoration: underline; } +/* One-line index rows: content cells clip, never wrap. */ +.data-table td.cell-clip { + max-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* Risk module — severity-colored scores, statement masthead, residual markers */ .sev-low { color: var(--status-ok); } .sev-medium { color: var(--status-warn); } diff --git a/src/opal/web/static/js/events.js b/src/opal/web/static/js/events.js index d33a99c..c826f1d 100644 --- a/src/opal/web/static/js/events.js +++ b/src/opal/web/static/js/events.js @@ -214,283 +214,3 @@ document.addEventListener('DOMContentLoaded', () => { window.addEventListener('beforeunload', () => { window.opalEvents.disconnect(); }); - - -/** - * Execution collaboration helper - */ -class ExecutionCollaboration { - constructor(instanceId) { - this.instanceId = instanceId; - this.participants = []; - this.setupEventListeners(); - } - - /** - * Setup SSE event listeners for this execution - */ - setupEventListeners() { - // Step events - window.opalEvents.on('step_started', (data) => { - if (data.instance_id === this.instanceId) { - this.onStepStarted(data); - } - }); - - window.opalEvents.on('step_completed', (data) => { - if (data.instance_id === this.instanceId) { - this.onStepCompleted(data); - } - }); - - // User events - window.opalEvents.on('user_joined', (data) => { - if (data.instance_id === this.instanceId) { - this.onUserJoined(data); - } - }); - - window.opalEvents.on('user_left', (data) => { - if (data.instance_id === this.instanceId) { - this.onUserLeft(data); - } - }); - - // Instance completion - window.opalEvents.on('instance_completed', (data) => { - if (data.instance_id === this.instanceId) { - this.onInstanceCompleted(data); - } - }); - } - - /** - * Join this execution - */ - async join() { - if (!window.OPAL_USER_ID) return; - - try { - const response = await fetch(`/api/procedure-instances/${this.instanceId}/join`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (response.ok) { - const data = await response.json(); - this.updateParticipants(data.participants); - window.opalEvents.setActivity(`executing:${this.instanceId}`); - } - } catch (e) { - console.error('ExecutionCollaboration: Failed to join', e); - } - } - - /** - * Leave this execution - */ - async leave() { - if (!window.OPAL_USER_ID) return; - - try { - await fetch(`/api/procedure-instances/${this.instanceId}/leave`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }); - - window.opalEvents.setActivity(null); - } catch (e) { - console.error('ExecutionCollaboration: Failed to leave', e); - } - } - - /** - * Load current participants - */ - async loadParticipants() { - try { - const response = await fetch(`/api/procedure-instances/${this.instanceId}/participants`); - - if (response.ok) { - const data = await response.json(); - this.updateParticipants(data.participants); - } - } catch (e) { - console.error('ExecutionCollaboration: Failed to load participants', e); - } - } - - /** - * Update participants display - */ - updateParticipants(participants) { - this.participants = participants; - this.renderParticipants(); - } - - /** - * Render participants UI - */ - renderParticipants() { - const container = document.getElementById('participants-list'); - if (!container) return; - - container.innerHTML = this.participants.map(p => ` -
- ${p.user_name.charAt(0).toUpperCase()} - ${p.user_name} - ${p.last_step ? `Step ${p.last_step}` : ''} -
- `).join(''); - } - - /** - * Handle step started event - */ - onStepStarted(data) { - const stepEl = document.getElementById(`step-${data.step_number}`) || - document.getElementById(`op-${data.step_number}`); - - if (stepEl) { - // Update status indicator - const statusEl = stepEl.querySelector('.status'); - if (statusEl) { - statusEl.className = 'status status-info'; - statusEl.textContent = 'IN PROGRESS'; - } - - // Show who started it (if not current user) - const currentUserId = window.OPAL_USER_ID; - if (data.user_id !== currentUserId && data.user_name) { - this.showStepActivity(stepEl, `${data.user_name} started this step`); - } - } - - // Flash notification - this.showNotification(`Step ${data.step_number} started${data.user_name ? ` by ${data.user_name}` : ''}`); - } - - /** - * Handle step completed event - */ - onStepCompleted(data) { - const stepEl = document.getElementById(`step-${data.step_number}`) || - document.getElementById(`op-${data.step_number}`); - - if (stepEl) { - // Update status indicator - const statusEl = stepEl.querySelector('.status'); - if (statusEl) { - statusEl.className = 'status status-ok'; - statusEl.textContent = 'COMPLETED'; - } - - // Hide buttons - const actionsEl = stepEl.querySelector('.step-actions'); - if (actionsEl) { - const buttons = actionsEl.querySelectorAll('button'); - buttons.forEach(btn => btn.style.display = 'none'); - } - } - - // Flash notification - const currentUserId = window.OPAL_USER_ID; - if (data.user_id !== currentUserId) { - this.showNotification(`Step ${data.step_number} completed${data.user_name ? ` by ${data.user_name}` : ''}`); - } - - // Update progress - this.updateProgress(); - } - - /** - * Handle user joined event - */ - onUserJoined(data) { - const currentUserId = window.OPAL_USER_ID; - if (data.user_id !== currentUserId) { - this.showNotification(`${data.user_name} joined the execution`); - } - this.loadParticipants(); - } - - /** - * Handle user left event - */ - onUserLeft(data) { - this.showNotification(`${data.user_name} left the execution`); - this.loadParticipants(); - } - - /** - * Handle instance completed event - */ - onInstanceCompleted(data) { - this.showNotification('Execution completed!'); - // Reload page to show final state - setTimeout(() => window.location.reload(), 1000); - } - - /** - * Show activity indicator on a step - */ - showStepActivity(stepEl, message) { - let activityEl = stepEl.querySelector('.step-activity'); - if (!activityEl) { - activityEl = document.createElement('div'); - activityEl.className = 'step-activity'; - stepEl.appendChild(activityEl); - } - activityEl.textContent = message; - activityEl.style.display = 'block'; - - // Hide after 5 seconds - setTimeout(() => { - activityEl.style.display = 'none'; - }, 5000); - } - - /** - * Show notification toast - */ - showNotification(message) { - // Create or get notification container - let container = document.getElementById('notification-container'); - if (!container) { - container = document.createElement('div'); - container.id = 'notification-container'; - document.body.appendChild(container); - } - - // Create notification element - const notification = document.createElement('div'); - notification.className = 'notification'; - notification.textContent = message; - container.appendChild(notification); - - // Remove after animation - setTimeout(() => { - notification.classList.add('fade-out'); - setTimeout(() => notification.remove(), 300); - }, 3000); - } - - /** - * Update progress display - */ - updateProgress() { - // This would require tracking step states client-side - // For now, just suggest a refresh might be needed - const progressEl = document.querySelector('.panel-body [style*="font-size: 2rem"]'); - if (progressEl) { - progressEl.style.opacity = '0.7'; - } - } -} - -// Export for use -window.ExecutionCollaboration = ExecutionCollaboration; diff --git a/src/opal/web/static/js/execdoc.js b/src/opal/web/static/js/execdoc.js new file mode 100644 index 0000000..bab8f7d --- /dev/null +++ b/src/opal/web/static/js/execdoc.js @@ -0,0 +1,1317 @@ +/** + * OPAL Execution Document — the multiplayer document. + * + * One server-rendered document; this file keeps it live: + * - 5s state poll (the primary channel — SSE accelerates it but MCP-driven + * mutations only surface through the poll) + * - in-place row/presence updates (spatial stability: rows swap, the + * document never reflows) + * - presence = focus: the cursor moves with arrows/j/k/click, is broadcast, + * and records no event; complete / skip / sign-off / issue act on the + * focused step from the docked bar + * - issue capture, evidence attach, lightbox, docked bar + */ + +/* global formatApiError, renderMarkdown, htmx */ + +(function () { + 'use strict'; + + const cfg = window.OPAL_EXEC || {}; + const instanceId = cfg.instanceId; + if (!instanceId) return; + + const POLL_MS = 5000; + const storageKey = `opal_execdoc_${instanceId}`; + + let lastState = null; + let pollTimer = null; + const appliedRowSigs = {}; + + // ---------- helpers ---------- + + function getHeaders() { + return { 'Content-Type': 'application/json' }; + } + + function uploadHeaders() { + // FormData bodies need fetch to set its own multipart boundary. + return {}; + } + + function apiUrl(path) { + return `/api/procedure-instances/${instanceId}${path}`; + } + + function pageUrl(path) { + return `/executions/${instanceId}${path}`; + } + + function toast(html, opts) { + let container = document.getElementById('execdoc-toasts'); + if (!container) { + container = document.createElement('div'); + container.id = 'execdoc-toasts'; + document.body.appendChild(container); + } + const note = document.createElement('div'); + note.className = 'execdoc-toast mono' + (opts && opts.error ? ' is-error' : ''); + // Server error details can echo user input (issue titles) — text by + // default; opts.html is reserved for trusted, server-generated markup. + if (opts && opts.html) note.innerHTML = html; + else note.textContent = html; + container.appendChild(note); + setTimeout(() => { + note.classList.add('fade-out'); + setTimeout(() => note.remove(), 300); + }, opts && opts.sticky ? 8000 : 3500); + } + + function toastError(detail, fallback) { + toast(formatApiError(detail, fallback), { error: true }); + } + + // ---------- persisted expand/collapse ---------- + + function loadToggles() { + try { return JSON.parse(localStorage.getItem(storageKey) || '{}'); } + catch (_) { return {}; } + } + + function saveToggle(key, value) { + const t = loadToggles(); + t[key] = value; + localStorage.setItem(storageKey, JSON.stringify(t)); + } + + function applyStoredToggles() { + const t = loadToggles(); + for (const [key, expanded] of Object.entries(t)) { + if (key.startsWith('op_')) { + const card = document.getElementById(key.replace('op_', 'op-')); + if (card) setOpExpanded(card, expanded, false); + } else if (key.startsWith('step_')) { + const row = document.getElementById(key.replace('step_', 'step-')); + if (row) { + const body = row.querySelector('.doc-step-body'); + if (body) body.hidden = !expanded; + } + } + } + } + + function setOpExpanded(card, expanded, persist) { + card.classList.toggle('is-collapsed', !expanded); + const body = card.querySelector('.op-card-body'); + if (body) body.hidden = !expanded; + const caret = card.querySelector('[data-caret]'); + if (caret) caret.textContent = expanded ? '▾' : '▸'; + if (persist) saveToggle(`op_${card.dataset.opOrder}`, expanded); + } + + window.toggleOp = function (order) { + const card = document.getElementById(`op-${order}`); + if (!card) return; + setOpExpanded(card, card.classList.contains('is-collapsed'), true); + }; + + window.toggleStepBody = function (order) { + const row = document.getElementById(`step-${order}`); + if (!row) return; + const body = row.querySelector('.doc-step-body'); + if (!body) return; + body.hidden = !body.hidden; + saveToggle(`step_${order}`, !body.hidden); + }; + + // ---------- partial refreshers ---------- + + async function refreshStepRow(order) { + try { + const resp = await fetch(pageUrl(`/step-row/${order}`)); + if (!resp.ok) return; + const html = await resp.text(); + const row = document.getElementById(`step-${order}`); + if (!row) return; + const wasOpen = !!row.querySelector('.doc-step-body:not([hidden])'); + const tpl = document.createElement('template'); + tpl.innerHTML = html.trim(); + const fresh = tpl.content.firstElementChild; + if (!fresh) return; + if (wasOpen) { + const body = fresh.querySelector('.doc-step-body'); + if (body) body.hidden = false; + } + row.replaceWith(fresh); + if (focusedOrder !== null && parseInt(fresh.dataset.order) === focusedOrder) { + fresh.classList.add('is-focused'); + } + if (typeof renderMarkdown === 'function') renderMarkdown(); + loadStepKitAvailability(); + } catch (e) { console.error('execdoc: row refresh failed', e); } + } + + function dockbarVisible() { + return localStorage.getItem('opal_dockbar') === '1'; + } + + function applyDockbarPref() { + document.body.classList.toggle('dockbar-off', !dockbarVisible()); + } + + window.toggleDockbar = function () { + localStorage.setItem('opal_dockbar', dockbarVisible() ? '0' : '1'); + applyDockbarPref(); + if (dockbarVisible()) refreshDockbar(); + }; + + function positionDockbar() { + const bar = document.getElementById('dockbar'); + if (!bar) return; + const doc = document.getElementById('exec-doc'); + if (doc) { + // Align the bar with the document column, not the viewport. + const rect = doc.getBoundingClientRect(); + bar.style.left = `${rect.left}px`; + bar.style.width = `${rect.width}px`; + bar.style.transform = 'none'; + } else { + bar.style.left = ''; + bar.style.width = ''; + bar.style.transform = ''; + } + } + window.addEventListener('resize', positionDockbar); + + async function refreshDockbar(opts = {}) { + if (!dockbarVisible() && !opts.force) return; + try { + const url = pageUrl('/dockbar') + (focusedOrder !== null ? `?step=${focusedOrder}` : ''); + const resp = await fetch(url); + if (!resp.ok) return; + const html = await resp.text(); + const node = document.getElementById('dockbar'); + if (!node) return; + const tpl = document.createElement('template'); + tpl.innerHTML = html.trim(); + const fresh = tpl.content.firstElementChild; + if (fresh) node.replaceWith(fresh); + document.body.classList.toggle( + 'has-dockbar', + !!document.querySelector('#dockbar .dockbar') + ); + positionDockbar(); + } catch (e) { console.error('execdoc: dockbar refresh failed', e); } + } + + async function refreshRail() { + const rail = document.getElementById('exec-rail-content'); + if (!rail) return; + try { + const resp = await fetch(pageUrl('/rail')); + if (!resp.ok) return; + const html = await resp.text(); + const tpl = document.createElement('template'); + tpl.innerHTML = html.trim(); + const fresh = tpl.content.firstElementChild; + if (fresh) rail.replaceWith(fresh); + } catch (e) { console.error('execdoc: rail refresh failed', e); } + } + + // ---------- state poll ---------- + + function stepSignature(s) { + // Cursors are NOT part of the row signature: presence chips update + // in place (renderPresence) — a colleague's cursor move must never + // re-render a row someone is typing into. + return JSON.stringify([ + s.status, + s.holds.map((h) => h.issue_number + h.disposition_state), + s.attachments, + // Count + latest timestamp: a note appended remotely (MCP, another + // operator) re-renders the row's note trail on the next poll. + s.notes.length, + s.notes.length ? s.notes[s.notes.length - 1].created_at : null, + ]); + } + + // A row is busy while its control surface holds focus or unsaved input — + // swapping it would destroy the operator's half-entered data. + function rowBusy(order) { + const row = document.getElementById(`step-${order}`); + if (!row) return false; + const active = document.activeElement; + if (active && row.contains(active) + && ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName)) return true; + return Array.from(row.querySelectorAll('input, textarea')).some((f) => { + if (f.type === 'checkbox') return f.checked !== f.defaultChecked; + if (f.type === 'hidden') return false; + return f.value !== f.defaultValue; + }); + } + + // The bar refetches only when the focused step's commitment state moves — + // never on presence/evidence churn (half-entered field values survive). + function barSignature(s) { + return JSON.stringify([s.status, s.holds.map((h) => h.issue_number + h.disposition_state)]); + } + + function applyState(state) { + const prev = lastState; + lastState = state; + + // Header progress. + const progress = document.querySelector('[data-progress]'); + if (progress) { + progress.textContent = `${state.instance.progress.done}/${state.instance.progress.total}`; + } + const fill = document.querySelector('[data-progress-fill]'); + if (fill && state.instance.progress.total) { + fill.style.width = `${(state.instance.progress.done / state.instance.progress.total) * 100}%`; + } + + renderPresence(state); + + // Step diffs → swap changed rows in place. appliedRowSigs tracks what + // each DOM row currently reflects: a swap skipped while the operator + // types retries on the next poll instead of being lost. + if (prev) { + for (const s of state.steps) { + if (!document.getElementById(`step-${s.order}`)) continue; + const sig = stepSignature(s); + if (appliedRowSigs[s.order] === undefined) { appliedRowSigs[s.order] = sig; continue; } + if (appliedRowSigs[s.order] !== sig && !rowBusy(s.order)) { + appliedRowSigs[s.order] = sig; + refreshStepRow(s.order); + } + } + // OP progress counters. + updateOpProgress(state); + // Rail refresh when holds or evidence change. + const holdsSig = JSON.stringify(state.holds); + const attachSig = JSON.stringify(state.steps.map((s) => s.attachments)); + const prevHoldsSig = JSON.stringify(prev.holds); + const prevAttachSig = JSON.stringify(prev.steps.map((s) => s.attachments)); + if (holdsSig !== prevHoldsSig || attachSig !== prevAttachSig) refreshRail(); + + // The focused step's commitment state moved remotely → refetch bar. + if (focusedOrder !== null) { + const prevFocused = prev.steps.find((s) => s.order === focusedOrder); + const liveFocused = state.steps.find((s) => s.order === focusedOrder); + if (prevFocused && liveFocused + && barSignature(prevFocused) !== barSignature(liveFocused)) { + refreshDockbar(); + } + } + } else { + state.steps.forEach((s) => { appliedRowSigs[s.order] = stepSignature(s); }); + updateOpProgress(state); + } + } + + function activeHolds(s) { + return (s.holds || []).filter((h) => h.disposition_state === 'undispositioned'); + } + + function updateOpProgress(state) { + const leavesByParent = {}; + const holdsByParent = {}; + for (const s of state.steps) { + if (s.parent_order === null || s.parent_order === undefined) continue; + const bucket = leavesByParent[s.parent_order] || (leavesByParent[s.parent_order] = { done: 0, total: 0 }); + bucket.total += 1; + if (['completed', 'signed_off', 'skipped'].includes(s.status)) bucket.done += 1; + const hb = holdsByParent[s.parent_order] || (holdsByParent[s.parent_order] = []); + hb.push(...activeHolds(s)); + } + for (const s of state.steps) { + if (s.level !== 0) continue; + const card = document.getElementById(`op-${s.order}`); + if (!card) continue; + const el = card.querySelector('[data-op-progress]'); + if (!el) continue; + const bucket = leavesByParent[s.order] || { done: ['completed', 'signed_off', 'skipped'].includes(s.status) ? 1 : 0, total: 1 }; + const done = ['completed', 'signed_off', 'skipped'].includes(s.status); + el.textContent = `${bucket.done}/${bucket.total}`; + el.classList.toggle('is-done', done); + const mini = document.querySelector(`[data-minimap-op="${s.order}"] [data-minimap-prog]`); + if (mini) { + mini.textContent = `${bucket.done}/${bucket.total}`; + mini.classList.toggle('is-done', done); + } + // HELD BY blockline: own + child holds, both kinds ('raised' + // containment and 'bound' resolve-by — a bound child hold gates + // the OP's completion), deduped; hidden when the last disposition + // is signed. Same derivation as op_holds_by_order server-side (F4). + const holdEl = card.querySelector('[data-op-holds]'); + if (holdEl) { + const seen = new Set(); + const holds = activeHolds(s).concat(holdsByParent[s.order] || []) + .filter((h) => !seen.has(h.issue_id) && seen.add(h.issue_id)); + if (holds.length) { + holdEl.innerHTML = 'HELD BY ' + holds.map((h) => + `${h.issue_number}` + ).join(' · '); + holdEl.hidden = false; + } else { + holdEl.hidden = true; + holdEl.innerHTML = ''; + } + } + } + } + + function minutesSince(iso) { + if (!iso) return 0; + return Math.floor((Date.now() - new Date(iso).getTime()) / 60000); + } + + function rosterRow(r, myId) { + const row = document.createElement('div'); + row.className = 'roster-row mono' + + (r.stale ? ' is-stale' : '') + + (r.user_id === myId ? ' is-self' : ''); + let label = r.name || r.initials || '?'; + if (r.step_number) label += ` @${r.step_number}`; + row.textContent = label; + if (r.step_order !== null && r.step_order !== undefined) { + row.onclick = () => jumpToStep(r.step_order); + } + return row; + } + + function renderPresence(state) { + const myId = window.OPAL_USER_ID; + const roster = state.roster; + const online = roster.filter((r) => !r.stale).length; + + const counter = document.querySelector('[data-online]'); + if (counter) counter.textContent = `${online} ONLINE`; + + const pop = document.getElementById('exec-roster-pop'); + if (pop) { + pop.innerHTML = ''; + for (const r of roster) pop.appendChild(rosterRow(r, myId)); + } + + const railRoster = document.querySelector('[data-rail-roster]'); + if (railRoster) { + railRoster.innerHTML = ''; + if (!roster.length) { + const empty = document.createElement('div'); + empty.className = 'empty-line mono'; + empty.textContent = 'Online — none'; + railRoster.appendChild(empty); + } else { + for (const r of roster) railRoster.appendChild(rosterRow(r, myId)); + } + } + + // Per-step cursor chips in the right gutter. + for (const s of state.steps) { + const row = document.getElementById(`step-${s.order}`); + if (!row) continue; + const slot = row.querySelector('[data-cursors]'); + if (!slot) continue; + slot.innerHTML = ''; + for (const c of s.cursors) { + const chip = document.createElement('span'); + chip.className = 'cursor-chip mono' + + (c.user_id === myId ? ' is-self' : '') + + (c.stale ? ' is-stale' : ''); + chip.textContent = c.initials || '?'; + chip.title = c.name || ''; + slot.appendChild(chip); + } + } + } + + window.toggleRosterPop = function (event) { + event.stopPropagation(); + const pop = document.getElementById('exec-roster-pop'); + if (pop) pop.hidden = !pop.hidden; + }; + + async function pollState() { + try { + const resp = await fetch(apiUrl('/state')); + if (!resp.ok) return; + applyState(await resp.json()); + } catch (e) { /* transient network errors: next tick retries */ } + } + + function pollNow() { + pollState(); + } + + function startPolling() { + if (pollTimer) clearInterval(pollTimer); + pollTimer = setInterval(pollState, POLL_MS); + pollState(); + } + + // ---------- jump-follow ---------- + + window.jumpToStep = function (order) { + const row = document.getElementById(`step-${order}`) || document.getElementById(`op-${order}`); + if (!row) return; + const card = row.closest('.op-card'); + if (card && card.classList.contains('is-collapsed')) setOpExpanded(card, true, false); + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + row.classList.add('pulse'); + setTimeout(() => row.classList.remove('pulse'), 1600); + }; + + window.toggleRail = function () { + const layout = document.getElementById('exec-doc-layout'); + if (layout) layout.classList.toggle('rail-open'); + }; + + window.railFocusStep = function (order) { + const group = document.getElementById(`rail-step-${order}`); + if (group) { + group.scrollIntoView({ behavior: 'smooth', block: 'center' }); + group.classList.add('pulse'); + setTimeout(() => group.classList.remove('pulse'), 1600); + } + }; + + // ---------- focus (presence) ---------- + + let focusedOrder = null; + let focusPostTimer = null; + + function focusableRows() { + return Array.from(document.querySelectorAll('#exec-doc .doc-step[data-order]')); + } + + function postFocus(order) { + clearTimeout(focusPostTimer); + focusPostTimer = setTimeout(async () => { + try { + await fetch(apiUrl('/focus'), { + method: 'POST', headers: getHeaders(), + body: JSON.stringify({ step_number: order }), + }); + } catch (e) { /* presence is best-effort; the next move retries */ } + }, 250); + } + + function setFocus(order, opts = {}) { + const row = document.getElementById(`step-${order}`); + if (!row) return; + if (focusedOrder !== null && focusedOrder !== order) { + const prevRow = document.getElementById(`step-${focusedOrder}`); + if (prevRow) prevRow.classList.remove('is-focused'); + } + const moved = focusedOrder !== order; + focusedOrder = order; + row.classList.add('is-focused'); + const card = row.closest('.op-card'); + if (card && card.classList.contains('is-collapsed')) setOpExpanded(card, true, false); + if (opts.scroll) row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + if (moved || opts.force) { + refreshDockbar(); + if (opts.post !== false) postFocus(order); + } + } + + window.onStepRowClick = function (order) { + setFocus(order); + toggleStepBody(order); + }; + + document.addEventListener('keydown', (e) => { + if (e.target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName) || e.target.isContentEditable)) return; + if (document.querySelector('.execdoc-modal:not([hidden])')) return; + if (e.key === 'Enter' && focusedOrder !== null) { + e.preventDefault(); + toggleStepBody(focusedOrder); + return; + } + const down = e.key === 'j' || e.key === 'ArrowDown'; + const up = e.key === 'k' || e.key === 'ArrowUp'; + if (!down && !up) return; + e.preventDefault(); + const rows = focusableRows(); + if (!rows.length) return; + let idx = rows.findIndex((r) => parseInt(r.dataset.order) === focusedOrder); + if (idx === -1) idx = down ? -1 : rows.length; + idx = Math.min(rows.length - 1, Math.max(0, idx + (down ? 1 : -1))); + setFocus(parseInt(rows[idx].dataset.order), { scroll: true }); + }); + + // ---------- step actions ---------- + + function collectCaptureData(container) { + if (!container) return { data: null, errors: [] }; + const fields = container.querySelectorAll('[data-capture-field]'); + if (!fields.length) return { data: null, errors: [] }; + const data = {}; + const errors = []; + fields.forEach((f) => { + const name = f.dataset.captureField; + if (f.type === 'checkbox') data[name] = f.checked; + else if (f.type === 'number') data[name] = f.value ? parseFloat(f.value) : null; + else if (f.dataset.captureFieldType === 'photo') { + let ids = []; + try { ids = JSON.parse(f.value || '[]'); } catch (_) { ids = []; } + if (!Array.isArray(ids)) ids = ids ? [ids] : []; + data[name] = ids.length ? ids : null; + } else data[name] = f.value || null; + }); + fields.forEach((f) => { + const name = f.dataset.captureField; + const val = data[name]; + if (f.hasAttribute('required') + && (val === null || val === '' || val === undefined + || (Array.isArray(val) && val.length === 0))) { + const label = f.closest('.bar-field')?.querySelector('.bar-field-label'); + errors.push((label ? label.textContent.replace(/\s*\*\s*$/, '').trim() : name) + ' is required'); + } + if (f.type === 'number' && val !== null && val !== undefined && val !== '') { + const num = parseFloat(val); + if (f.hasAttribute('min') && num < parseFloat(f.min)) errors.push(`${name}: ${num} below minimum ${f.min}`); + if (f.hasAttribute('max') && num > parseFloat(f.max)) errors.push(`${name}: ${num} above maximum ${f.max}`); + } + }); + return { data, errors }; + } + + window.completeStep = async function (order, btn) { + const body = {}; + let container = btn ? btn.closest('.doc-step-body, .dockbar') : null; + if (!container) { + const bar = document.getElementById('dockbar'); + if (bar && bar.dataset.barOrder === String(order)) container = bar.querySelector('.dockbar'); + } + if (container) { + const { data, errors } = collectCaptureData(container); + if (errors.length) { + toastError(errors, 'Cannot complete'); + return; + } + if (data) body.data_captured = data; + } + try { + const resp = await fetch(apiUrl(`/steps/${order}/complete`), { + method: 'POST', headers: getHeaders(), body: JSON.stringify(body), + }); + if (resp.ok) { + await refreshDockbar({ force: true }); + await refreshStepRow(order); + pollNow(); + } else { + const err = await resp.json(); + toastError(err.detail, 'Failed to complete step'); + } + } catch (e) { toastError(null, 'Network error'); } + }; + + window.signoffStep = async function (order) { + try { + const resp = await fetch(apiUrl(`/steps/${order}/signoff`), { method: 'POST', headers: getHeaders() }); + if (resp.ok) { + await refreshDockbar(); + await refreshStepRow(order); + pollNow(); + } else { + const err = await resp.json(); + toastError(err.detail, 'Failed to sign off step'); + } + } catch (e) { toastError(null, 'Network error'); } + }; + + // Append-only: posts one timestamped note and clears the input. Empty + // input is a no-op (blur fires on every focus change). + window.addStepNote = async function (order, input) { + const body = input.value.trim(); + if (!body) return; + try { + const resp = await fetch(apiUrl(`/steps/${order}/notes`), { + method: 'POST', headers: getHeaders(), + body: JSON.stringify({ body }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + toastError(err.detail, 'Failed to add note'); + return; + } + input.value = ''; + input.defaultValue = ''; + await refreshStepRow(order); + pollNow(); + } catch (e) { toastError(null, 'Network error'); } + }; + + window.abortExecution = async function () { + const wo = cfg.workOrder || 'this work order'; + if (!confirm(`Abort ${wo}? In-progress work stops, the work order closes as ABORTED, and it cannot be resumed.`)) return; + try { + const resp = await fetch(apiUrl(''), { + method: 'PATCH', headers: getHeaders(), body: JSON.stringify({ status: 'aborted' }), + }); + if (resp.ok) window.location.reload(); + else { + const err = await resp.json(); + toastError(err.detail, 'Failed to abort execution'); + } + } catch (e) { toastError(null, 'Network error'); } + }; + + async function ensureJoined() { + // Opening the document is presence — no JOIN ceremony. + try { await fetch(apiUrl('/join'), { method: 'POST', headers: getHeaders() }); } + catch (e) { /* presence is best-effort */ } + } + + window.addEventListener('pagehide', () => { + navigator.sendBeacon(apiUrl('/leave')); + }); + + // ---------- meta popover ---------- + + window.toggleMetaPopover = function (event) { + event.stopPropagation(); + const pop = document.getElementById('exec-meta-popover'); + if (pop) pop.hidden = !pop.hidden; + }; + + document.addEventListener('click', (e) => { + const pop = document.getElementById('exec-meta-popover'); + if (pop && !pop.hidden && !pop.contains(e.target) && e.target.id !== 'exec-meta-btn') { + pop.hidden = true; + } + }); + + // ---------- issue capture ---------- + + window.showIssueModal = function (order, label) { + document.getElementById('anomaly-step').value = order; + document.getElementById('anomaly-step-label').textContent = label; + document.getElementById('anomaly-form').reset(); + document.getElementById('anomaly-step').value = order; + // RESOLVE BY default = the raised step; name its consequence. + const boundaryDefault = document.querySelector('#anomaly-boundary option[value=""]'); + if (boundaryDefault) boundaryDefault.textContent = `this step — holds ${label} COMPLETE`; + document.getElementById('anomaly-error').hidden = true; + document.getElementById('anomaly-modal').hidden = false; + document.getElementById('anomaly-title').focus(); + }; + + window.hideAnomalyModal = function () { + document.getElementById('anomaly-modal').hidden = true; + }; + window.showAnomalyModal = window.showIssueModal; + + window.submitAnomaly = async function (event) { + event.preventDefault(); + const errorDiv = document.getElementById('anomaly-error'); + errorDiv.hidden = true; + const order = document.getElementById('anomaly-step').value; + const boundary = document.getElementById('anomaly-boundary').value; + const payload = { + title: document.getElementById('anomaly-title').value, + priority: document.getElementById('anomaly-priority').value, + should_be: document.getElementById('anomaly-should-be').value || null, + actual: document.getElementById('anomaly-is').value || null, + containment: document.getElementById('anomaly-containment').value, + containment_step_number: boundary ? parseInt(boundary) : null, + }; + try { + const resp = await fetch(apiUrl(`/steps/${order}/nc`), { + method: 'POST', headers: getHeaders(), body: JSON.stringify(payload), + }); + if (!resp.ok) { + const err = await resp.json(); + errorDiv.textContent = formatApiError(err.detail, 'Failed to raise issue'); + errorDiv.hidden = false; + return; + } + const issue = await resp.json(); + + // Evidence at discovery: photos link to the issue AND the step. + const files = document.getElementById('anomaly-photos').files; + const row = document.getElementById(`step-${order}`); + const seId = row ? row.dataset.seId : ''; + for (const file of files) { + const fd = new FormData(); + fd.append('file', file); + fd.append('issue_id', issue.id); + if (seId) fd.append('step_execution_id', seId); + fd.append('kind', 'capture'); + await fetch('/api/attachments/upload', { method: 'POST', headers: uploadHeaders(), body: fd }); + } + + const assign = document.getElementById('anomaly-assign').value; + if (assign) { + await fetch(`/api/issues/${issue.id}`, { + method: 'PATCH', headers: getHeaders(), + body: JSON.stringify({ assigned_to_id: parseInt(assign) }), + }); + } + + hideAnomalyModal(); + toast(`${issue.issue_number} →`, { sticky: true, html: true }); + await refreshStepRow(parseInt(order)); + refreshRail(); + pollNow(); + } catch (e) { + errorDiv.textContent = 'Network error: ' + e.message; + errorDiv.hidden = false; + } + }; + + // ---------- skip ---------- + + window.showSkipModal = function (order, title) { + document.getElementById('skip-step').value = order; + document.getElementById('skip-step-title').textContent = title; + document.getElementById('skip-error').hidden = true; + document.getElementById('skip-modal').hidden = false; + }; + + window.hideSkipModal = function () { + document.getElementById('skip-modal').hidden = true; + document.getElementById('skip-form').reset(); + }; + + window.submitSkip = async function (event) { + event.preventDefault(); + const errorDiv = document.getElementById('skip-error'); + errorDiv.hidden = true; + const order = document.getElementById('skip-step').value; + try { + const resp = await fetch(apiUrl(`/steps/${order}/skip`), { + method: 'POST', headers: getHeaders(), + body: JSON.stringify({ reason: document.getElementById('skip-reason').value || null }), + }); + if (resp.ok) { + hideSkipModal(); + await refreshStepRow(parseInt(order)); + pollNow(); + } else { + const err = await resp.json(); + errorDiv.textContent = formatApiError(err.detail, 'Failed to skip step'); + errorDiv.hidden = false; + } + } catch (e) { + errorDiv.textContent = 'Network error: ' + e.message; + errorDiv.hidden = false; + } + }; + + // ---------- attach (execution captures) ---------- + + window.showAttachModal = function (seId, label) { + document.getElementById('attach-se-id').value = seId || ''; + document.getElementById('attach-step-label').textContent = label ? `to ${label}` : ''; + document.getElementById('attach-closeout-row').hidden = !!seId; + document.getElementById('attach-error').hidden = true; + document.getElementById('attach-form').reset(); + document.getElementById('attach-se-id').value = seId || ''; + document.getElementById('attach-modal').hidden = false; + }; + + window.hideAttachModal = function () { + document.getElementById('attach-modal').hidden = true; + }; + + window.submitAttach = async function (event) { + event.preventDefault(); + const errorDiv = document.getElementById('attach-error'); + errorDiv.hidden = true; + const seId = document.getElementById('attach-se-id').value; + const files = document.getElementById('attach-file').files; + const note = document.getElementById('attach-note').value.trim(); + const closeout = document.getElementById('attach-closeout').checked; + if (!files.length) return; + try { + for (const file of files) { + const fd = new FormData(); + fd.append('file', file); + if (seId) fd.append('step_execution_id', seId); + else fd.append('procedure_instance_id', instanceId); + fd.append('kind', !seId && closeout ? 'closeout' : 'capture'); + if (note) fd.append('note', note); + const resp = await fetch('/api/attachments/upload', { + method: 'POST', headers: uploadHeaders(), body: fd, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + errorDiv.textContent = formatApiError(err.detail, 'Upload failed'); + errorDiv.hidden = false; + return; + } + } + hideAttachModal(); + refreshRail(); + pollNow(); + } catch (e) { + errorDiv.textContent = 'Network error: ' + e.message; + errorDiv.hidden = false; + } + }; + + // ---------- docked-bar photo capture fields ---------- + + function renderStepPhotoGallery(container, ids, fieldName) { + const gallery = container.querySelector('.step-photo-gallery'); + if (!gallery) return; + gallery.innerHTML = ''; + for (const id of ids) { + const item = document.createElement('div'); + item.className = 'step-photo-item'; + item.dataset.attachmentId = String(id); + const img = document.createElement('img'); + img.className = 'step-photo-thumb'; + img.alt = fieldName; + img.src = `/api/attachments/${id}/download`; + img.onclick = () => openLightbox(img.src, fieldName); + item.appendChild(img); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-sm'; + btn.textContent = 'REMOVE'; + btn.onclick = function () { removeStepPhoto(btn); }; + item.appendChild(btn); + gallery.appendChild(item); + } + const hidden = container.querySelector('input[type="hidden"][data-capture-field]'); + if (hidden) hidden.value = JSON.stringify(ids); + } + + window.uploadStepPhotos = async function (input) { + if (!input.files.length) return; + const container = input.closest('.step-photo-capture'); + if (!container) return; + const seId = container.dataset.stepExecutionId; + const fieldName = container.dataset.fieldName; + const isMultiple = container.dataset.multiple === 'true'; + const hidden = container.querySelector('input[type="hidden"][data-capture-field]'); + let existing = []; + try { existing = JSON.parse(hidden.value || '[]'); } catch (_) { existing = []; } + if (!Array.isArray(existing)) existing = existing ? [existing] : []; + + const newIds = []; + for (const file of input.files) { + if (!file.type.startsWith('image/')) continue; + const fd = new FormData(); + fd.append('file', file); + fd.append('step_execution_id', seId); + fd.append('kind', 'capture'); + try { + const r = await fetch('/api/attachments/upload', { + method: 'POST', headers: uploadHeaders(), body: fd, + }); + if (!r.ok) { + const err = await r.json().catch(() => ({})); + toastError(err.detail, 'Photo upload failed'); + continue; + } + const data = await r.json(); + newIds.push(data.id); + } catch (e) { toastError(null, 'Network error'); } + } + const updated = isMultiple ? [...existing, ...newIds] : newIds.slice(-1); + renderStepPhotoGallery(container, updated, fieldName); + input.value = ''; + pollNow(); + }; + + window.removeStepPhoto = function (buttonOrItem) { + const item = buttonOrItem.closest('.step-photo-item'); + if (!item) return; + const container = item.closest('.step-photo-capture'); + const removeId = parseInt(item.dataset.attachmentId); + const hidden = container.querySelector('input[type="hidden"][data-capture-field]'); + let existing = []; + try { existing = JSON.parse(hidden.value || '[]'); } catch (_) { existing = []; } + if (!Array.isArray(existing)) existing = existing ? [existing] : []; + renderStepPhotoGallery(container, existing.filter((id) => id !== removeId), container.dataset.fieldName); + }; + + // ---------- redline (ad-hoc op) ---------- + + window.showRedlineModal = function (hostOrder, hostNumberStr, issueIds, issueNumbers) { + document.getElementById('redline-host-order').value = hostOrder; + document.getElementById('redline-host-label').textContent = 'on OP ' + hostNumberStr; + const sel = document.getElementById('redline-issue-id'); + sel.innerHTML = ''; + (issueIds || []).forEach((id, i) => { + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = (issueNumbers && issueNumbers[i]) || 'NC'; + sel.appendChild(opt); + }); + document.getElementById('redline-title').value = ''; + document.getElementById('redline-steps').innerHTML = ''; + addRedlineStep(); + document.getElementById('redline-error').hidden = true; + document.getElementById('redline-modal').hidden = false; + }; + + window.hideRedlineModal = function () { + document.getElementById('redline-modal').hidden = true; + }; + + window.addRedlineStep = function () { + const container = document.getElementById('redline-steps'); + const idx = container.children.length + 1; + const row = document.createElement('div'); + row.className = 'redline-step-row'; + row.innerHTML = ` +
+ ${idx}. + + + +
+ + `; + container.appendChild(row); + }; + + window.removeRedlineStep = function (btn) { + const row = btn.closest('.redline-step-row'); + const container = row.parentElement; + if (container.children.length <= 1) return; + row.remove(); + Array.from(container.children).forEach((r, i) => { + const label = r.querySelector('.mono'); + if (label) label.textContent = (i + 1) + '.'; + }); + }; + + window.submitRedline = async function (event) { + event.preventDefault(); + const errorDiv = document.getElementById('redline-error'); + errorDiv.hidden = true; + const issueId = parseInt(document.getElementById('redline-issue-id').value); + const title = document.getElementById('redline-title').value.trim(); + const steps = Array.from(document.querySelectorAll('#redline-steps .redline-step-row')).map((r) => ({ + title: r.querySelector('.redline-step-title').value.trim(), + instructions: r.querySelector('.redline-step-instructions').value || null, + requires_signoff: r.querySelector('.redline-step-signoff').checked, + })); + if (!issueId || !title || steps.length === 0 || steps.some((s) => !s.title)) { + errorDiv.textContent = 'NC, title, and at least one sub-step (with a title) are required.'; + errorDiv.hidden = false; + return; + } + try { + const r = await fetch(apiUrl('/ad-hoc-ops'), { + method: 'POST', headers: getHeaders(), + body: JSON.stringify({ issue_id: issueId, title, steps }), + }); + if (r.ok) window.location.reload(); // a new OP card enters the document + else { + const err = await r.json(); + errorDiv.textContent = formatApiError(err.detail, 'Failed to create redline'); + errorDiv.hidden = false; + } + } catch (e) { + errorDiv.textContent = 'Network error: ' + e.message; + errorDiv.hidden = false; + } + }; + + // ---------- lightbox ---------- + + window.openLightbox = function (src, caption) { + const box = document.getElementById('lightbox'); + if (!box) return; + document.getElementById('lightbox-img').src = src; + document.getElementById('lightbox-caption').textContent = caption || ''; + box.hidden = false; + }; + + window.closeLightbox = function () { + const box = document.getElementById('lightbox'); + if (box) box.hidden = true; + }; + + // ---------- kit availability (KITTING tab + step kits) ---------- + + // The one home for the consume-from option label: the physical-item + // identity first, then where it sits and how much it is. + // "OPAL-00164 · STORE-A2 · 5 EA · LOT-2026-03" (lot only when present). + function invSourceLabel(opalNumber, location, quantity, uom, lotNumber) { + const parts = []; + if (opalNumber) parts.push(opalNumber); + parts.push(location); + parts.push(`${Number(quantity)} ${(uom || 'EA').toUpperCase()}`); + if (lotNumber) parts.push(lotNumber); + return parts.join(' · '); + } + + async function loadKitAvailability() { + if (!document.getElementById('kit-table')) return; + try { + const resp = await fetch(apiUrl('/kit-availability'), { headers: getHeaders() }); + if (resp.ok) populateKitTable((await resp.json()).items); + } catch (e) { console.error('execdoc: kit availability failed', e); } + } + + function populateKitTable(items) { + for (const item of items) { + const row = document.querySelector(`tr[data-part-id="${item.part_id}"]`); + if (!row) continue; + const availCell = row.querySelector('.availability-cell'); + if (!availCell) continue; + availCell.textContent = item.quantity_available.toFixed(4); + if (!item.is_available) availCell.classList.add('text-red'); + const select = row.querySelector('.inv-select'); + if (!select) continue; + select.innerHTML = ''; + for (const loc of item.available_locations) { + const opt = document.createElement('option'); + opt.value = loc.inventory_record_id; + opt.textContent = invSourceLabel( + loc.opal_number, loc.location, loc.quantity, item.uom, loc.lot_number); + select.appendChild(opt); + } + const required = parseFloat(row.dataset.required); + for (const loc of item.available_locations) { + if (loc.quantity >= required) { select.value = loc.inventory_record_id; break; } + } + } + } + + window.consumeParts = async function () { + const errorDiv = document.getElementById('consume-error'); + errorDiv.style.display = 'none'; + const items = []; + const rows = document.querySelectorAll('#kit-table tbody tr'); + for (const row of rows) { + const select = row.querySelector('.inv-select'); + const qtyInput = row.querySelector('.qty-input'); + if (!select.value) { errorDiv.textContent = 'Please select a location for all parts'; errorDiv.style.display = 'block'; return; } + const qty = parseFloat(qtyInput.value); + if (qty <= 0) continue; + items.push({ inventory_record_id: parseInt(select.value), quantity: qty }); + } + if (items.length === 0) { errorDiv.textContent = 'No parts to consume'; errorDiv.style.display = 'block'; return; } + try { + const resp = await fetch(apiUrl('/consume'), { + method: 'POST', headers: getHeaders(), body: JSON.stringify({ items }), + }); + if (resp.ok) window.location.reload(); + else { const err = await resp.json(); errorDiv.textContent = formatApiError(err.detail, 'Failed to consume parts'); errorDiv.style.display = 'block'; } + } catch (e) { errorDiv.textContent = 'Network error: ' + e.message; errorDiv.style.display = 'block'; } + }; + + window.produceOutput = async function () { + const errorDiv = document.getElementById('produce-error'); + errorDiv.style.display = 'none'; + const items = []; + const rows = document.querySelectorAll('#output-table tbody tr'); + for (const row of rows) { + const partId = row.dataset.partId; + const qty = parseFloat(row.querySelector(`input[name="out_${partId}_qty"]`).value); + const loc = row.querySelector(`input[name="out_${partId}_loc"]`).value.trim(); + if (qty > 0) { + if (!loc) { errorDiv.textContent = 'Please enter a location for all output items'; errorDiv.style.display = 'block'; return; } + items.push({ + part_id: parseInt(partId), quantity: qty, location: loc, + lot_number: row.querySelector(`input[name="out_${partId}_lot"]`).value.trim() || null, + serial_number: row.querySelector(`input[name="out_${partId}_serial"]`).value.trim() || null, + }); + } + } + if (items.length === 0) { errorDiv.textContent = 'No output items to record'; errorDiv.style.display = 'block'; return; } + try { + const resp = await fetch(apiUrl('/produce'), { + method: 'POST', headers: getHeaders(), body: JSON.stringify({ items }), + }); + if (resp.ok) window.location.reload(); + else { const err = await resp.json(); errorDiv.textContent = formatApiError(err.detail, 'Failed to record output'); errorDiv.style.display = 'block'; } + } catch (e) { errorDiv.textContent = 'Network error: ' + e.message; errorDiv.style.display = 'block'; } + }; + + window.finalizeProduction = async function () { + const locationInput = document.getElementById('finalize-location'); + const errorDiv = document.getElementById('finalize-error'); + if (errorDiv) errorDiv.style.display = 'none'; + const location = locationInput ? locationInput.value.trim() : ''; + if (!location) { if (errorDiv) { errorDiv.textContent = 'Please enter a storage location'; errorDiv.style.display = 'block'; } return; } + if (!confirm('Finalize production? This will set output quantities and record assembly genealogy.')) return; + try { + const resp = await fetch(apiUrl('/finalize'), { + method: 'POST', headers: getHeaders(), body: JSON.stringify({ location }), + }); + if (resp.ok) window.location.reload(); + else { + const err = await resp.json(); + const msg = formatApiError(err.detail, 'Failed to finalize production'); + if (errorDiv) { errorDiv.textContent = msg; errorDiv.style.display = 'block'; } else toastError(null, msg); + } + } catch (e) { if (errorDiv) { errorDiv.textContent = 'Network error: ' + e.message; errorDiv.style.display = 'block'; } } + }; + + async function loadStepKitAvailability() { + const tables = document.querySelectorAll('[data-step-kit]'); + if (!tables.length) return; + const partIds = new Set(); + tables.forEach((t) => t.querySelectorAll('tbody tr[data-part-id]').forEach((r) => partIds.add(r.dataset.partId))); + const inventoryByPart = {}; + await Promise.all([...partIds].map(async (pid) => { + try { + const resp = await fetch(`/api/inventory?part_id=${pid}&page_size=100`, { headers: getHeaders() }); + if (resp.ok) inventoryByPart[pid] = await resp.json(); + } catch (e) { console.error('execdoc: inventory load failed', pid, e); } + })); + document.querySelectorAll('.sk-inv-select').forEach((select) => { + const pid = select.dataset.partId; + const records = inventoryByPart[pid]; + select.innerHTML = ''; + if (!records) return; + const items = records.items || records; + for (const rec of items) { + if (parseFloat(rec.quantity) <= 0) continue; + const opt = document.createElement('option'); + opt.value = rec.id; + opt.textContent = invSourceLabel( + rec.opal_number, rec.location, rec.quantity, rec.part_uom, rec.lot_number); + select.appendChild(opt); + } + const row = select.closest('tr'); + const required = parseFloat(row?.dataset.required || 0); + for (const rec of items) { + if (parseFloat(rec.quantity) >= required) { select.value = rec.id; break; } + } + }); + } + window.loadStepKitAvailability = loadStepKitAvailability; + + window.consumeStepParts = async function (order) { + const table = document.querySelector(`[data-step-kit="${order}"]`); + if (!table) return; + const items = []; + for (const row of table.querySelectorAll('tbody tr')) { + const select = row.querySelector('.sk-inv-select'); + const qtyInput = row.querySelector('.sk-qty-input'); + if (!select || !select.value) { toastError(null, 'Select a source location for all parts'); return; } + const qty = parseFloat(qtyInput.value); + if (qty <= 0) continue; + const typeBadge = row.querySelector('.status'); + const usageType = typeBadge && typeBadge.textContent.trim().toLowerCase() === 'tooling' ? 'tooling' : 'consume'; + items.push({ inventory_record_id: parseInt(select.value), quantity: qty, usage_type: usageType }); + } + if (items.length === 0) { toastError(null, 'No parts to consume'); return; } + try { + const resp = await fetch(apiUrl(`/steps/${order}/consume`), { + method: 'POST', headers: getHeaders(), body: JSON.stringify({ items }), + }); + if (resp.ok) refreshStepRow(order); + else { const err = await resp.json(); toastError(err.detail, 'Failed to consume step parts'); } + } catch (e) { toastError(null, 'Network error'); } + }; + + // ---------- wiring ---------- + + window.toggleDockbarMenu = function (event) { + event.stopPropagation(); + const menu = document.getElementById('dockbar-menu'); + if (menu) menu.hidden = !menu.hidden; + }; + + window.hideDockbarMenu = function () { + const menu = document.getElementById('dockbar-menu'); + if (menu) menu.hidden = true; + }; + + document.addEventListener('click', (e) => { + const menu = document.getElementById('dockbar-menu'); + if (menu && !menu.hidden && !menu.contains(e.target)) menu.hidden = true; + const pop = document.getElementById('exec-roster-pop'); + if (pop && !pop.hidden && !pop.contains(e.target)) pop.hidden = true; + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + window.hideAnomalyModal(); + window.hideSkipModal(); + window.hideAttachModal(); + window.hideRedlineModal(); + window.closeLightbox(); + window.hideDockbarMenu(); + const pop = document.getElementById('exec-meta-popover'); + if (pop) pop.hidden = true; + const roster = document.getElementById('exec-roster-pop'); + if (roster) roster.hidden = true; + } + }); + + function setFooterOffset() { + const footer = document.querySelector('.footer'); + document.documentElement.style.setProperty( + '--opal-footer-h', footer ? `${footer.offsetHeight}px` : '0px' + ); + } + window.addEventListener('resize', setFooterOffset); + + function initExecDoc() { + setFooterOffset(); + applyDockbarPref(); + positionDockbar(); + applyStoredToggles(); + loadKitAvailability(); + loadStepKitAvailability(); + document.body.classList.toggle( + 'has-dockbar', + !!document.querySelector('#dockbar .dockbar') + ); + if (lastState) renderPresence(lastState); + + // Place the cursor: the server already resolved my cursor or the + // first actionable row into the bar — adopt it locally. + const bar = document.getElementById('dockbar'); + const initial = cfg.myCursorOrder !== null && cfg.myCursorOrder !== undefined + ? cfg.myCursorOrder + : (bar && bar.dataset.barOrder ? parseInt(bar.dataset.barOrder) : null); + if (initial !== null && focusedOrder === null) { + const row = document.getElementById(`step-${initial}`); + if (row) { + focusedOrder = initial; + row.classList.add('is-focused'); + postFocus(initial); + } + } + + // A HOLDING link (holds.py exec_href) lands here with ?op=N — jump to + // that step, expanding its collapsed OP card. jumpToStep is idempotent. + const opParam = new URLSearchParams(window.location.search).get('op'); + if (opParam !== null && opParam !== '') { + const opOrder = parseInt(opParam, 10); + if (!Number.isNaN(opOrder)) jumpToStep(opOrder); + } + } + + document.addEventListener('DOMContentLoaded', () => { + initExecDoc(); + ensureJoined(); + startPolling(); + + // SSE accelerates the poll; the poll remains the source of truth. + if (window.opalEvents) { + ['cursor_moved', 'step_completed', 'user_joined', 'user_left', 'issue_dispositioned'].forEach((type) => { + window.opalEvents.on(type, (data) => { + if (data && data.instance_id === instanceId) pollNow(); + }); + }); + window.opalEvents.on('instance_completed', (data) => { + if (data && data.instance_id === instanceId) { + toast('Work order complete'); + setTimeout(() => window.location.reload(), 1500); + } + }); + } + }); + + document.body.addEventListener('htmx:afterSwap', (e) => { + if (e.target && e.target.classList && e.target.classList.contains('exec-tab-content')) { + initExecDoc(); + } + }); +})(); diff --git a/src/opal/web/static/js/risks.js b/src/opal/web/static/js/risks.js index 1240983..9b96d79 100644 --- a/src/opal/web/static/js/risks.js +++ b/src/opal/web/static/js/risks.js @@ -88,7 +88,8 @@ async function refreshAcceptancePanel() { // confirm dialog mid-signature. const confirmEl = document.getElementById('accept-confirm'); const confirmWasOpen = confirmEl && confirmEl.style.display !== 'none'; - const r = await fetch(`/risks/${riskId}/acceptance-panel`); + const editing = typeof EDITING !== 'undefined' && EDITING; + const r = await fetch(`/risks/${riskId}/acceptance-panel${editing ? '?edit=1' : ''}`); if (r.ok) container.innerHTML = await r.text(); if (confirmWasOpen && document.getElementById('accept-confirm')) openAcceptConfirm(); } catch (e) { /* panel refresh is cosmetic; the next save retries */ } @@ -174,10 +175,7 @@ async function applyDisposition(target) { // ============ Acceptance — the signature ============ function openAcceptConfirm() { - const confirm = document.getElementById('accept-confirm'); - confirm.style.display = 'block'; - document.getElementById('accept-sign-time').textContent = - new Date().toISOString().substring(0, 19) + 'Z'; + document.getElementById('accept-confirm').style.display = 'flex'; } async function commitAccept() { diff --git a/src/opal/web/templates/datasets/detail.html b/src/opal/web/templates/datasets/detail.html index 415129a..d37a8c6 100644 --- a/src/opal/web/templates/datasets/detail.html +++ b/src/opal/web/templates/datasets/detail.html @@ -4,7 +4,7 @@ {% block breadcrumbs %} {{ ok.crumb("HOME", "/") }} {{ ok.crumb("DATASETS", "/datasets") }} -{{ ok.crumb(dataset.id) }} +{{ ok.crumb(dataset.name) }} {% endblock %} {% block content %} @@ -19,7 +19,6 @@ {% endif %} {% call ok.table() %} - {{ ok.detail_row("ID", ok.id_cell(dataset.id)) }} {{ ok.detail_row("POINTS", ok.mono(data_points | length)) }} {{ ok.detail_row("CREATED", ok.timestamp(dataset.created_at)) }} {% if dataset.procedure_id %} diff --git a/src/opal/web/templates/datasets/list.html b/src/opal/web/templates/datasets/list.html index 86f2940..770be64 100644 --- a/src/opal/web/templates/datasets/list.html +++ b/src/opal/web/templates/datasets/list.html @@ -25,7 +25,6 @@ {% call ok.table() %} - {{ ok.th("ID", width="50px") }} {{ ok.th("NAME") }} {{ ok.th("PROCEDURE", width="200px") }} {{ ok.th("POINTS", width="100px") }} @@ -33,7 +32,7 @@ - Loading... + Loading... {% endcall %} {% endcall %} diff --git a/src/opal/web/templates/datasets/table_rows.html b/src/opal/web/templates/datasets/table_rows.html index 8f1d6e2..27f4cb0 100644 --- a/src/opal/web/templates/datasets/table_rows.html +++ b/src/opal/web/templates/datasets/table_rows.html @@ -1,8 +1,7 @@ {% import 'opalkit/_macros.html' as ok %} {% for dataset in datasets %} - {{ ok.id_cell(dataset.id) }} - {{ dataset.name }} + {{ dataset.name }} {% if dataset.procedure %} {{ dataset.procedure.name }} @@ -14,6 +13,6 @@ {{ ok.timestamp(dataset.updated_at) }} {% else %} -{{ ok.empty("No datasets found") }} +{{ ok.empty("No datasets found") }} {% endfor %} {% include 'partials/pagination_row.html' %} diff --git a/src/opal/web/templates/errors/404.html b/src/opal/web/templates/errors/404.html index d664fc5..fd26c8e 100644 --- a/src/opal/web/templates/errors/404.html +++ b/src/opal/web/templates/errors/404.html @@ -4,7 +4,7 @@ 404 - Not Found - OPAL - +
diff --git a/src/opal/web/templates/executions/_dockbar.html b/src/opal/web/templates/executions/_dockbar.html new file mode 100644 index 0000000..193d825 --- /dev/null +++ b/src/opal/web/templates/executions/_dockbar.html @@ -0,0 +1,74 @@ +{# Docked bar — the only control surface of the document. Renders the + FOCUSED step (cursor position); every action on a step happens here: + [notes] [ATTACH] [ISSUE] [COMPLETE], SKIP and the rarer actions in the + overflow. Refetched as the cursor moves. #} +{% import 'executions/_exec_macros.html' as ex with context %} +
+{% if bar_step %} + {% set actionable = inst_status in ['cut', 'in_work'] %} + {% set bs = bar_step.status %} + {% set workable = actionable and bs in ['pending', 'in_progress'] %} + {% set menu_skip = workable %} + {% set menu_consume = workable and bar_step.step_kit and not bar_step.has_children %} + {# op_open_ncs is the one redline-visibility predicate (already empty + for ad-hoc ops — see op_open_ncs_by_order). #} + {% set menu_redline = bar_step.op_open_ncs %} +
+
+ {{ bar_step.number }} + {{ bar_step.title }} + {% if bs in ['completed', 'signed_off'] %} + COMPLETE{% if bar_step.exec and bar_step.exec.completed_by_user %} {{ bar_step.exec.completed_by_user.name | initials }}{% endif %} {{ ex.hm(bar_step.exec.completed_at) if bar_step.exec else '' }} + {% elif bs == 'skipped' %} + SKIPPED + {% elif bs == 'on_hold' %} + HOLD + {% endif %} +
+ {% if bar_step.caution %} +
CAUTION: {{ bar_step.caution }}
+ {% endif %} + {% if workable and bar_step.exec and not bar_step.has_children %} +
+ {{ ex.data_fields_editable(bar_step) }} +
+ {% endif %} + {# The notes log: separate from the capture-field stack so it never + reads as required data — available in any status. #} + {% if bar_step.exec %}{{ ex.notes_block(bar_step.exec, bar_step.order) }}{% endif %} +
+ {% if bar_step.exec %} + + {% endif %} + {% if actionable %} + + {% if menu_skip or menu_consume or menu_redline %} +
+ + +
+ {% endif %} + {% if actionable and bs == 'awaiting_signoff' %} + + {% elif workable %} + {# OP rows included: the gate (complete_gate_by_se) carries the + children blocker, so a parent over open children renders the + standard inert control + "Waiting on sub-steps …" — never an + absent control (inert + reason doctrine). #} + {{ ex.complete_control(bar_step.order, bar_step.complete_blockers) }} + {% endif %} + {% endif %} +
+
+{% endif %} +
diff --git a/src/opal/web/templates/executions/_exec_macros.html b/src/opal/web/templates/executions/_exec_macros.html new file mode 100644 index 0000000..896d582 --- /dev/null +++ b/src/opal/web/templates/executions/_exec_macros.html @@ -0,0 +1,203 @@ +{# Shared execution-document macros. Import as: {% import 'executions/_exec_macros.html' as ex %} #} + +{% macro role_chip(role) %}{% if role %}{{ role }}{% endif %}{% endmacro %} + +{% macro hm(dt) %}{% if dt %}{{ dt.strftime('%H:%M') }}{% endif %}{% endmacro %} + +{# State cell: words in state colors, never a symbol. Exactly one of: + complete · skipped · hold · awaiting sign-off · blocker text · nothing. #} +{% macro state_cell(step, bound_holds_by_se, step_holding_ncs, gate_text, op_on_hold) %} +{% set exec = step.execution %} +{% set se_id = exec.id if exec else none %} +{% set bound = bound_holds_by_se.get(se_id, []) if se_id else [] %} +{% set raised = step_holding_ncs.get(se_id, []) if se_id else [] %} +{% if step.status in ['completed', 'signed_off'] %} + {% if exec and exec.completed_by_user %}{{ exec.completed_by_user.name | initials }}{% endif %} {{ hm(exec.completed_at) if exec else '' }} +{% elif step.status == 'skipped' %} + SKIPPED{% if exec and exec.completed_by_user %} {{ exec.completed_by_user.name | initials }}{% endif %} +{% elif raised %} + {# R2 derived hold: step stays pending, hold derived from undispositioned issue. #} + {% for nc in raised %}HELD — {{ nc.issue_number }}{% endfor %} +{% elif step.status == 'on_hold' %} + HOLD{% for nc in raised %} · {{ nc.issue_number }}{% endfor %} +{% elif bound %} + HOLD{% for i in bound %} · {{ i.issue_number }}{% endfor %} +{% elif step.status == 'awaiting_signoff' %} + AWAITING SIGN-OFF +{% elif gate_text %} + {{ gate_text }} +{% elif op_on_hold and step.status == 'pending' %} + OP ON HOLD +{% endif %} +{% endmacro %} + +{# Why a gated control is inert: issue-backed blockers link, structural ones + (sequence/redline) render their message. Rendered beside the disabled + button — never a control that silently vanishes. #} +{% macro gate_reason(blockers) %}{% for b in blockers %}{% if b.issue_number %}held by {{ b.issue_number }}{% else %}{{ b.message }}{% endif %}{% if not loop.last %}; {% endif %}{% endfor %}{% endmacro %} + +{# The COMPLETE control, from the single server-computed gate (F4): the active + button when nothing holds the step, else inert + reason — the button + renders disabled with the blocker line beside it (same register as the + risk page's gated ACCEPT). `blockers` is a list of + core.execution_flow.Blocker. The server gate stays authoritative; never + re-derive gating here. #} +{% macro complete_control(order, blockers) %} +{%- if blockers %} +{{ gate_reason(blockers) }} +{%- else %} + +{%- endif %} +{% endmacro %} + +{# + REDLINE — visible wherever its op carries an open NC. The one + visibility predicate is a non-empty `open_ncs` (op_open_ncs_by_order in + the route context, which already excludes ad-hoc/redline ops); the one JS + entry point is showRedlineModal. Renders in the step action rows and the + dockbar overflow. #} +{% macro redline_control(op_order, op_number, open_ncs, before='') %} +{%- if open_ncs %} + +{%- endif %} +{% endmacro %} + +{# The SKIP control, from the F5 gate — same inert + reason register. + `menu` renders the dockbar-overflow variant. #} +{% macro skip_control(order, title, blockers, menu=false) %} +{%- if blockers %} +{{ gate_reason(blockers) }} +{%- else %} + +{%- endif %} +{% endmacro %} + +{# Row indicators: counts as words, absent when zero (empty-state rule). #} +{% macro indicators(step, attach_counts, vs) %} +{% set exec = step.execution %} +{% set n_att = attach_counts.get(exec.id, 0) if exec else 0 %} +{% set n_note = step_notes_by_se.get(exec.id, []) | length if exec else 0 %} +{% if n_att or n_note %} + + {%- if n_att %}{{ n_att }} ATT{% endif -%} + {%- if n_att and n_note %} · {% endif -%} + {%- if n_note %}{{ n_note }} NOTE{{ 'S' if n_note > 1 else '' }}{% endif -%} + +{% endif %} +{% endmacro %} + +{# The running notes log: timestamped, authored, append-only. One muted mono + line per note (HH:MM with the full ISO-8601 in the tooltip), chronological, + with the append input underneath. Deliberately its OWN labeled block, set + off by a hairline rule — the notes log must never read as another + data-capture field (rehearsal feedback). Notes are a record, not a + control: rendered for any step status, any work order status. #} +{% macro notes_block(exec, order) %} +
+ NOTES +
+ {% for n in step_notes_by_se.get(exec.id, []) %} +
{{ hm(n.created_at) }} {{ (n.author.name | initials) if n.author else '—' }} · {{ n.body }}
+ {% endfor %} + +
+
+{% endmacro %} + +{# Presence cursors for one row — bordered initials chips in the right + gutter; the session user's own chip renders filled/inverted. #} +{% macro cursor_chips(step, cursors_by_se, current_user) %} +{% set exec = step.execution %} + +{%- if exec %}{% for c in cursors_by_se.get(exec.id, []) %}{{ (c.user.name if c.user else none) | initials }}{% endfor %}{% endif -%} + +{% endmacro %} + +{# Authored reference imagery — instruction content, the one inline exception. #} +{% macro step_images(vs) %} +{% set images = vs.get('images') or [] %} +{% if images %} +
+ {% for img in images %} +
+ {{ img.caption or 'Reference image' }} + {% if img.caption %}
{{ img.caption }}
{% endif %} +
+ {% endfor %} +
+{% endif %} +{% endmacro %} + +{# Read-only data capture readout (editing happens in the docked bar only). #} +{% macro data_readout(step) %} +{% set exec = step.execution %} +{% set schema = step.required_data_schema %} +{% if schema and schema.get('fields') %} +
+ {% for field in schema.get('fields', []) %} +
+ {{ field.label }}{% if field.get('unit') %} ({{ field.unit }}){% endif %}{% if field.required %} *{% endif %} + {% set raw = exec.data_captured[field.name] if exec and exec.data_captured and exec.data_captured[field.name] is defined else none %} + {% if field.type == 'photo' %} + {% set ids = raw if raw is iterable and raw is not string else ([raw] if raw else []) %} + + {% for att_id in ids %} + {{ field.label }} + {% else %}{% endfor %} + + {% elif field.type == 'checkbox' %} + {% if raw is none %}—{% elif raw %}YES{% else %}NO{% endif %} + {% else %} + {% if raw is none or raw == '' %}—{% else %}{{ raw }}{% endif %} + {% endif %} +
+ {% endfor %} +
+{% endif %} +{% endmacro %} + +{# Editable data capture fields — used by the docked bar exclusively. #} +{% macro data_fields_editable(my_step) %} +{% set exec = my_step.exec %} +{% set schema = my_step.schema %} +{% if schema and schema.get('fields') %} +{% for field in schema.get('fields', []) %} +
+ + {% set raw = exec.data_captured[field.name] if exec.data_captured and exec.data_captured[field.name] is defined else none %} + {% if field.type == 'text' %} + + {% elif field.type == 'number' %} + + {% elif field.type == 'checkbox' %} + + {% elif field.type == 'select' %} + + {% elif field.type == 'textarea' %} + + {% elif field.type == 'photo' %} + {% set ids = raw if raw is iterable and raw is not string else ([raw] if raw else []) %} + {% set is_multiple = field.get('multiple', False) %} +
+ + + +
+ {% endif %} +
+{% endfor %} +{% endif %} +{% endmacro %} diff --git a/src/opal/web/templates/executions/_op_card.html b/src/opal/web/templates/executions/_op_card.html new file mode 100644 index 0000000..4415603 --- /dev/null +++ b/src/opal/web/templates/executions/_op_card.html @@ -0,0 +1,52 @@ +{# One OP card. Context: op_data plus page context. Expansion policy + (Abby's rule): nothing loads collapsed unless it is complete. #} +{% import 'opalkit/_macros.html' as ok %} +{% import 'executions/_exec_macros.html' as ex with context %} +{% set op = op_data.step %} +{% set op_vs = version_steps_map.get(op.order, {}) %} +{% set op_terminal = op.status in ['completed', 'signed_off', 'skipped'] %} +{% set op_holds = op_holds_by_order.get(op.order, []) %} +{% set op_gated = gated_ops_by_order.get(op.order) %} +{% if op_data.is_ad_hoc and op.ad_hoc_issue %} +REDLINE — {{ op.ad_hoc_issue.issue_number }} +{% endif %} + diff --git a/src/opal/web/templates/executions/_rail.html b/src/opal/web/templates/executions/_rail.html new file mode 100644 index 0000000..1c393d9 --- /dev/null +++ b/src/opal/web/templates/executions/_rail.html @@ -0,0 +1,116 @@ +{# The rail: issues/holds, attachments (evidence), reference docs. + Heavy content renders here, never inline in the document (Abby's rule). #} +{% import 'opalkit/_macros.html' as ok %} +
+
+
ISSUES / HOLDS
+ {% set hold_entries = exec_state.holds %} + {% set hold_issue_ids = hold_entries | map(attribute='issue_id') | list %} + {% if hold_entries %} + + + {% for h in hold_entries %} + + + + + + {% endfor %} + +
{{ h.issue_number }}{% if h.blocks %}→ blocks {{ h.blocks | join(', ') }}{% endif %}{{ ok.issue_state(h.disposition_state) }}
+ {% endif %} + {% set other_issues = linked_issues | rejectattr('id', 'in', hold_issue_ids) | list %} + {% if other_issues %} + + + {% for iss in other_issues %} + + + + + + {% endfor %} + +
{{ iss.issue_number or '—' }}{{ iss.title }}{{ ok.issue_state(iss.disp_state) }}
+ {% endif %} + {% if not hold_entries and not other_issues %} + {{ ok.empty_line("Issues") }} + {% endif %} +
+ +
+
ATTACHMENTS
+ {% set wo_attachments = [] %} + {% for att in instance.attachments %}{% if not att.step_execution_id %}{% set _ = wo_attachments.append(att) %}{% endif %}{% endfor %} + {% set step_orders = {} %} + {% if capture_attachments or wo_attachments %} +
+ {% for se in instance.step_executions | sort(attribute='step_number') %} + {% set atts = capture_attachments.get(se.id, []) %} + {% if atts %} +
+
{{ se.step_number_str or se.step_number }}
+ {% for att in atts %} +
+ {% if att.mime_type.startswith('image/') %} + {{ att.original_filename }} + {% else %} + {{ att.original_filename }} + {% endif %} +
+ {% if att.uploaded_by %}{{ att.uploaded_by.name }} · {% endif %}{{ att.created_at.strftime('%H:%M') }} +
+ {% if att.note %}
{{ att.note }}
{% endif %} +
+ {% endfor %} +
+ {% endif %} + {% endfor %} + {% if wo_attachments %} +
+
WORK ORDER
+ {% for att in wo_attachments %} +
+ {% if att.mime_type.startswith('image/') %} + {{ att.original_filename }} + {% else %} + {{ att.original_filename }} + {% endif %} +
+ {% if att.kind == 'closeout' %}CLOSEOUT · {% endif %}{% if att.uploaded_by %}{{ att.uploaded_by.name }} · {% endif %}{{ att.created_at.strftime('%H:%M') }} +
+
+ {% endfor %} +
+ {% endif %} +
+ {% else %} + {{ ok.empty_line("Attachments", onclick="showAttachModal(null, 'WO')") }} + {% endif %} + {% if (capture_attachments or wo_attachments) and inst_status in ['cut', 'in_work', 'completed'] %} +
+ +
+ {% endif %} +
+ +
+
REFERENCE DOCS
+ {% if reference_attachments %} + + + {% for a in reference_attachments %} + + + + + {% endfor %} + +
{{ a.original_filename }}{{ '{:.1f}'.format(a.size_bytes / 1024) }} KB
+ {% else %} + {{ ok.empty_line("Reference documents") }} + {% endif %} +
+
diff --git a/src/opal/web/templates/executions/_step_row.html b/src/opal/web/templates/executions/_step_row.html new file mode 100644 index 0000000..faffc4a --- /dev/null +++ b/src/opal/web/templates/executions/_step_row.html @@ -0,0 +1,130 @@ +{# One step row of the document. Context: step (dict), op_data, row_is_op, + plus the page context (cursors_by_se, bound_holds_by_se, step_holding_ncs, + version_steps_map, attach_counts, step_consumptions, gated_ops_by_order, + seq_blockers_by_order, inst_status, current_user). Rows carry state and + indicators ONLY — every control lives in the docked bar. Spatial + stability: this node is replaced in place when the step changes remotely. #} +{% import 'opalkit/_macros.html' as ok %} +{% import 'executions/_exec_macros.html' as ex with context %} +{% set op = op_data.step %} +{% set exec = step.execution %} +{% set vs = version_steps_map.get(step.order, {}) %} +{% set is_terminal = step.status in ['completed', 'signed_off', 'skipped'] %} +{% set step_display_number = step.step_number if '.' in step.step_number or row_is_op else (op.step_number ~ '.' ~ step.step_number) %} +{% if row_is_op %} + {% set gate_text = 'WAITING ON OP ' ~ gated_ops_by_order[step.order] | join(', ') if gated_ops_by_order.get(step.order) else none %} + {% set parent_held = false %} +{% else %} + {% set gate_text = ('WAITING ON OP ' ~ gated_ops_by_order[op.order] | join(', ')) if gated_ops_by_order.get(op.order) else seq_blockers_by_order.get(step.order) %} + {% set parent_held = op.status == 'on_hold' %} +{% endif %} +
+
+ {{ ex.role_chip(vs.get('required_role')) }} + {{ step_display_number }} + {{ step.title }} + {{ ex.indicators(step, attach_counts, vs) }} + + {{ ex.state_cell(step, bound_holds_by_se, step_holding_ncs, gate_text, parent_held) }} + + {{ ex.cursor_chips(step, cursors_by_se, current_user) }} +
+ +
diff --git a/src/opal/web/templates/executions/detail.html b/src/opal/web/templates/executions/detail.html index 098fb73..1f27d2a 100644 --- a/src/opal/web/templates/executions/detail.html +++ b/src/opal/web/templates/executions/detail.html @@ -3,30 +3,73 @@ {% block body_class %}exec-detail{% endblock %} +{% block head_extra %} + +{% endblock %} + {% block breadcrumbs %} {{ ok.crumb("HOME", "/") }} {{ ok.crumb("EXECUTIONS", "/executions") }} -{{ ok.crumb(instance.work_order_number or ("#" ~ instance.id)) }} +{{ ok.crumb(instance.work_order_number or "EXECUTION") }} {% endblock %} {% block content %} {% set inst_status = instance.status.value if instance.status.value is defined else instance.status %} -{# Tab navigation #} +{# ============ Header: identity · progress · presence · actions ============ #} +
+ {{ instance.work_order_number or 'EXECUTION' }} + {{ instance.procedure.name }} v{{ version.version_number if version else '—' }} + {{ ok.status(inst_status | upper | replace('_', ' ')) }} + {{ exec_state.instance.progress.done }}/{{ exec_state.instance.progress.total }} + + 0 ONLINE + + + {% if inst_status == 'completed' %} + {{ ok.btn("REPORT", href="/executions/" ~ instance.id ~ "/report", size="sm", attrs='target="_blank" rel="noopener"') }} + {% endif %} + {% if inst_status in ['cut', 'in_work'] %} + + {% endif %} + + + +
+ +{# Roster popover — the only roster when the rails are hidden. #} + + +{# Compact meta readout — every fact the META tab held. #} + + +{# Tab navigation — the document is the landing; META and OPERATIONS are gone. #}
- {% set tabs = [ - ('meta', 'META'), - ('operations', 'OPERATIONS'), - ('data', 'DATA'), - ('bom', 'BOM'), - ('issues', 'ISSUES' ~ (' (' ~ linked_issues|length ~ ')' if linked_issues else '')), - ('kitting', 'KITTING'), - ] %} + {# BOM renders when parts go IN (kit_relevant); KITTING also hosts + PRODUCTIONS + FINALIZE, so it renders when parts go in OR out + (material_relevant) — an irrelevant empty section is absent. #} + {% set tabs = [('document', 'DOCUMENT'), ('data', 'DATA')] %} + {% if kit_relevant %}{% set tabs = tabs + [('bom', 'BOM')] %}{% endif %} + {% set tabs = tabs + [('issues', 'ISSUES' ~ (' (' ~ linked_issues|length ~ ')' if linked_issues else ''))] %} + {% if material_relevant %}{% set tabs = tabs + [('kitting', 'KITTING')] %}{% endif %} {% for slug, label in tabs %} - {% set href = '?tab=' ~ slug ~ ('&op=' ~ selected_op_order if slug == 'operations' and selected_op_order else '') %} - - -