591 lines
27 KiB
Python
591 lines
27 KiB
Python
"""add repair parts tracking
|
|
|
|
Revision ID: 0014_repair_parts_tracking
|
|
Revises: 0013_control_board_replacement
|
|
Create Date: 2026-08-02
|
|
"""
|
|
|
|
from typing import Sequence, Union
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
from sqlalchemy.dialects import postgresql
|
|
|
|
|
|
revision: str = "0014_repair_parts_tracking"
|
|
down_revision: Union[str, None] = "0013_control_board_replacement"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
COMPONENT_TYPES = ("control_board", "hashboard", "power_supply", "fan")
|
|
CONSUMABLE_UNITS = ("each", "gram", "millilitre", "metre", "centimetre")
|
|
REPAIR_REASONS = (
|
|
"HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD",
|
|
"PSU", "FAN", "OVERHEATING", "NETWORKING",
|
|
"PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER",
|
|
)
|
|
REPLACEMENT_REASONS = (
|
|
"failed", "upgrade", "diagnostic_swap", "preventive_maintenance",
|
|
"temporary_donor", "unknown",
|
|
)
|
|
DISPOSITIONS = (
|
|
"repair_inventory", "sent_for_repair", "scrapped",
|
|
"retained_for_diagnosis", "returned_to_vendor", "unknown",
|
|
)
|
|
|
|
|
|
def _quoted(values: Sequence[str]) -> str:
|
|
return ", ".join(f"'{value}'" for value in values)
|
|
|
|
|
|
def _create_event_constraints(include_parts: bool) -> None:
|
|
event_types = [
|
|
"REPAIR_STARTED",
|
|
"INITIAL_NOTE",
|
|
"REPAIR_COMPLETED",
|
|
"CONTROL_BOARD_REPLACED",
|
|
]
|
|
if include_parts:
|
|
event_types.extend(("REPAIR_PART_RECORDED", "REPAIR_PART_VOIDED"))
|
|
|
|
op.create_check_constraint(
|
|
"ck_repair_events_type",
|
|
"repair_events",
|
|
f"event_type IN ({_quoted(event_types)})",
|
|
)
|
|
|
|
parts_clause = ""
|
|
if include_parts:
|
|
parts_clause = (
|
|
") OR (event_type = 'REPAIR_PART_RECORDED' "
|
|
"AND payload ?& ARRAY['repair_part_id', 'entry_kind', 'notes'] "
|
|
"AND payload ->> 'repair_part_id' ~ "
|
|
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
|
|
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
|
|
"AND (jsonb_typeof(payload -> 'notes') = 'null' OR ("
|
|
"jsonb_typeof(payload -> 'notes') = 'string' "
|
|
"AND trim(payload ->> 'notes') <> '' "
|
|
"AND char_length(payload ->> 'notes') <= 4000)) "
|
|
"AND ((payload ->> 'entry_kind' = 'consumable' "
|
|
"AND payload ?& ARRAY['description', 'quantity', 'unit'] "
|
|
"AND NOT payload ? 'component_id' "
|
|
"AND jsonb_typeof(payload -> 'description') = 'string' "
|
|
"AND trim(payload ->> 'description') <> '' "
|
|
"AND char_length(payload ->> 'description') <= 255 "
|
|
"AND jsonb_typeof(payload -> 'quantity') = 'string' "
|
|
"AND payload ->> 'quantity' ~ '^[0-9]{1,9}(\\.[0-9]{1,3})?$' "
|
|
"AND (payload ->> 'quantity')::numeric > 0 "
|
|
f"AND payload ->> 'unit' IN ({_quoted(CONSUMABLE_UNITS)}) "
|
|
"AND (payload ->> 'unit' <> 'each' OR "
|
|
"(payload ->> 'quantity')::numeric = "
|
|
"trunc((payload ->> 'quantity')::numeric))) OR ("
|
|
"payload ->> 'entry_kind' = 'tracked_component' "
|
|
"AND payload ?& ARRAY['component_id', 'component_type', "
|
|
"'component_asset_id', 'component_manufacturer', "
|
|
"'component_model', 'component_manufacturer_serial'] "
|
|
"AND NOT payload ? 'description' "
|
|
"AND NOT payload ? 'quantity' "
|
|
"AND NOT payload ? 'unit' "
|
|
"AND payload ->> 'component_id' ~ "
|
|
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
|
|
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
|
|
f"AND payload ->> 'component_type' IN ({_quoted(COMPONENT_TYPES)}) "
|
|
"AND jsonb_typeof(payload -> 'component_asset_id') IN ('string', 'null') "
|
|
"AND jsonb_typeof(payload -> 'component_manufacturer') IN ('string', 'null') "
|
|
"AND jsonb_typeof(payload -> 'component_model') IN ('string', 'null') "
|
|
"AND jsonb_typeof(payload -> 'component_manufacturer_serial') "
|
|
"IN ('string', 'null'))))"
|
|
" OR (event_type = 'REPAIR_PART_VOIDED' "
|
|
"AND payload ?& ARRAY['repair_part_id', 'reason'] "
|
|
"AND payload ->> 'repair_part_id' ~ "
|
|
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
|
|
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
|
|
"AND jsonb_typeof(payload -> 'reason') = 'string' "
|
|
"AND trim(payload ->> 'reason') <> '' "
|
|
"AND char_length(payload ->> 'reason') <= 1000"
|
|
)
|
|
|
|
op.create_check_constraint(
|
|
"ck_repair_events_payload_shape",
|
|
"repair_events",
|
|
"(event_type = 'REPAIR_STARTED' "
|
|
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
|
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
|
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
|
"AND payload ->> 'to_lifecycle' = 'in_repair') OR ("
|
|
"event_type = 'INITIAL_NOTE' "
|
|
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
|
"AND trim(payload ->> 'note') <> '') OR ("
|
|
"event_type = 'REPAIR_COMPLETED' "
|
|
"AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] "
|
|
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
|
|
"AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') "
|
|
"AND (NOT payload ? 'pre_repair_lifecycle' "
|
|
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
|
|
"AND (NOT payload ? 'completion_summary' OR ("
|
|
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
|
|
"AND trim(payload ->> 'completion_summary') <> '' "
|
|
"AND char_length(payload ->> 'completion_summary') <= 4000))) OR ("
|
|
"event_type = 'CONTROL_BOARD_REPLACED' "
|
|
"AND payload ?& ARRAY['old_mac_address', 'new_mac_address', "
|
|
"'old_control_board_serial', 'new_control_board_serial', "
|
|
"'replacement_reason', 'removed_board_disposition'] "
|
|
"AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') "
|
|
"AND jsonb_typeof(payload -> 'new_mac_address') = 'string' "
|
|
"AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
|
|
"AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') "
|
|
"NOT IN ('000000000000', 'ffffffffffff') "
|
|
"AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), "
|
|
"'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 "
|
|
"AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR ("
|
|
"jsonb_typeof(payload -> 'old_control_board_serial') = 'string' "
|
|
"AND trim(payload ->> 'old_control_board_serial') <> '' "
|
|
"AND char_length(payload ->> 'old_control_board_serial') <= 255)) "
|
|
"AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR ("
|
|
"jsonb_typeof(payload -> 'new_control_board_serial') = 'string' "
|
|
"AND trim(payload ->> 'new_control_board_serial') <> '' "
|
|
"AND char_length(payload ->> 'new_control_board_serial') <= 255)) "
|
|
f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) "
|
|
f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) "
|
|
"AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR ("
|
|
"jsonb_typeof(payload -> 'notes') = 'string' "
|
|
"AND trim(payload ->> 'notes') <> '' "
|
|
"AND char_length(payload ->> 'notes') <= 4000))"
|
|
f"{parts_clause})",
|
|
)
|
|
|
|
|
|
def upgrade() -> None:
|
|
connection = op.get_bind()
|
|
|
|
connection.exec_driver_sql(
|
|
"""
|
|
INSERT INTO permissions (key, description)
|
|
VALUES
|
|
('components.read', 'View tracked component identity'),
|
|
('components.create', 'Register tracked component identity'),
|
|
('repairs.parts.manage', 'Record and void repair-part usage')
|
|
ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description;
|
|
|
|
INSERT INTO role_permissions (role_id, permission_id)
|
|
SELECT roles.id, permissions.id
|
|
FROM roles
|
|
JOIN permissions ON permissions.key IN (
|
|
'components.read', 'components.create', 'repairs.parts.manage'
|
|
)
|
|
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
|
ON CONFLICT DO NOTHING;
|
|
|
|
INSERT INTO role_permissions (role_id, permission_id)
|
|
SELECT roles.id, permissions.id
|
|
FROM roles
|
|
JOIN permissions ON permissions.key = 'components.read'
|
|
WHERE roles.name = 'Read Only'
|
|
ON CONFLICT DO NOTHING;
|
|
"""
|
|
)
|
|
|
|
op.create_table(
|
|
"components",
|
|
sa.Column(
|
|
"id", postgresql.UUID(as_uuid=True), primary_key=True,
|
|
server_default=sa.text("gen_random_uuid()"),
|
|
),
|
|
sa.Column("component_type", sa.Text(), nullable=False),
|
|
sa.Column("asset_id", sa.Text(), nullable=True),
|
|
sa.Column("manufacturer", sa.Text(), nullable=True),
|
|
sa.Column("model", sa.Text(), nullable=True),
|
|
sa.Column("manufacturer_serial", sa.Text(), nullable=True),
|
|
sa.Column(
|
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.Column(
|
|
"created_by", postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
|
|
),
|
|
sa.CheckConstraint(
|
|
f"component_type IN ({_quoted(COMPONENT_TYPES)})",
|
|
name="ck_components_type",
|
|
),
|
|
sa.CheckConstraint(
|
|
"asset_id IS NULL OR (trim(asset_id) <> '' "
|
|
"AND asset_id = trim(asset_id) AND char_length(asset_id) <= 255)",
|
|
name="ck_components_asset_id",
|
|
),
|
|
sa.CheckConstraint(
|
|
"manufacturer IS NULL OR (trim(manufacturer) <> '' "
|
|
"AND manufacturer = trim(manufacturer) "
|
|
"AND char_length(manufacturer) <= 255)",
|
|
name="ck_components_manufacturer",
|
|
),
|
|
sa.CheckConstraint(
|
|
"model IS NULL OR (trim(model) <> '' AND model = trim(model) "
|
|
"AND char_length(model) <= 255)",
|
|
name="ck_components_model",
|
|
),
|
|
sa.CheckConstraint(
|
|
"manufacturer_serial IS NULL OR (trim(manufacturer_serial) <> '' "
|
|
"AND manufacturer_serial = trim(manufacturer_serial) "
|
|
"AND char_length(manufacturer_serial) <= 255)",
|
|
name="ck_components_manufacturer_serial",
|
|
),
|
|
)
|
|
op.create_index("idx_components_created_by", "components", ["created_by"])
|
|
op.create_index("idx_components_type", "components", ["component_type"])
|
|
op.create_index(
|
|
"uq_components_normalized_asset_id",
|
|
"components",
|
|
[sa.text("lower(trim(asset_id))")],
|
|
unique=True,
|
|
postgresql_where=sa.text("asset_id IS NOT NULL"),
|
|
)
|
|
|
|
op.create_table(
|
|
"repair_parts",
|
|
sa.Column(
|
|
"id", postgresql.UUID(as_uuid=True), primary_key=True,
|
|
server_default=sa.text("gen_random_uuid()"),
|
|
),
|
|
sa.Column(
|
|
"repair_id", postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=False,
|
|
),
|
|
sa.Column("consumable_description", sa.Text(), nullable=True),
|
|
sa.Column("quantity", sa.Numeric(precision=12, scale=3), nullable=True),
|
|
sa.Column("unit", sa.Text(), nullable=True),
|
|
sa.Column(
|
|
"component_id", postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("components.id", ondelete="RESTRICT"), nullable=True,
|
|
),
|
|
sa.Column("component_type_snapshot", sa.Text(), nullable=True),
|
|
sa.Column("component_asset_id_snapshot", sa.Text(), nullable=True),
|
|
sa.Column("component_manufacturer_snapshot", sa.Text(), nullable=True),
|
|
sa.Column("component_model_snapshot", sa.Text(), nullable=True),
|
|
sa.Column("component_manufacturer_serial_snapshot", sa.Text(), nullable=True),
|
|
sa.Column("notes", sa.Text(), nullable=True),
|
|
sa.Column(
|
|
"recorded_by", postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
|
|
),
|
|
sa.Column(
|
|
"recorded_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.Column(
|
|
"recorded_event_id", postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("repair_events.id", ondelete="RESTRICT"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"voided_by", postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True,
|
|
),
|
|
sa.Column("voided_at", sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column("void_reason", sa.Text(), nullable=True),
|
|
sa.Column(
|
|
"void_event_id", postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("repair_events.id", ondelete="RESTRICT"),
|
|
nullable=True,
|
|
),
|
|
sa.CheckConstraint(
|
|
"((consumable_description IS NOT NULL AND quantity IS NOT NULL "
|
|
"AND unit IS NOT NULL AND component_id IS NULL "
|
|
"AND component_type_snapshot IS NULL "
|
|
"AND component_asset_id_snapshot IS NULL "
|
|
"AND component_manufacturer_snapshot IS NULL "
|
|
"AND component_model_snapshot IS NULL "
|
|
"AND component_manufacturer_serial_snapshot IS NULL) OR "
|
|
"(consumable_description IS NULL AND quantity IS NULL "
|
|
"AND unit IS NULL AND component_id IS NOT NULL "
|
|
"AND component_type_snapshot IS NOT NULL))",
|
|
name="ck_repair_parts_entry_xor",
|
|
),
|
|
sa.CheckConstraint(
|
|
"quantity IS NULL OR quantity > 0",
|
|
name="ck_repair_parts_quantity_positive",
|
|
),
|
|
sa.CheckConstraint(
|
|
"unit IS NULL OR unit <> 'each' OR quantity = trunc(quantity)",
|
|
name="ck_repair_parts_each_whole",
|
|
),
|
|
sa.CheckConstraint(
|
|
f"unit IS NULL OR unit IN ({_quoted(CONSUMABLE_UNITS)})",
|
|
name="ck_repair_parts_unit",
|
|
),
|
|
sa.CheckConstraint(
|
|
f"component_type_snapshot IS NULL OR "
|
|
f"component_type_snapshot IN ({_quoted(COMPONENT_TYPES)})",
|
|
name="ck_repair_parts_component_type",
|
|
),
|
|
sa.CheckConstraint(
|
|
"consumable_description IS NULL OR ("
|
|
"consumable_description = trim(consumable_description) "
|
|
"AND trim(consumable_description) <> '' "
|
|
"AND char_length(consumable_description) <= 255)",
|
|
name="ck_repair_parts_description",
|
|
),
|
|
sa.CheckConstraint(
|
|
"component_asset_id_snapshot IS NULL OR ("
|
|
"component_asset_id_snapshot = trim(component_asset_id_snapshot) "
|
|
"AND trim(component_asset_id_snapshot) <> '' "
|
|
"AND char_length(component_asset_id_snapshot) <= 255)",
|
|
name="ck_repair_parts_asset_snapshot",
|
|
),
|
|
sa.CheckConstraint(
|
|
"component_manufacturer_snapshot IS NULL OR ("
|
|
"component_manufacturer_snapshot = trim(component_manufacturer_snapshot) "
|
|
"AND trim(component_manufacturer_snapshot) <> '' "
|
|
"AND char_length(component_manufacturer_snapshot) <= 255)",
|
|
name="ck_repair_parts_manufacturer_snapshot",
|
|
),
|
|
sa.CheckConstraint(
|
|
"component_model_snapshot IS NULL OR ("
|
|
"component_model_snapshot = trim(component_model_snapshot) "
|
|
"AND trim(component_model_snapshot) <> '' "
|
|
"AND char_length(component_model_snapshot) <= 255)",
|
|
name="ck_repair_parts_model_snapshot",
|
|
),
|
|
sa.CheckConstraint(
|
|
"component_manufacturer_serial_snapshot IS NULL OR ("
|
|
"component_manufacturer_serial_snapshot = "
|
|
"trim(component_manufacturer_serial_snapshot) "
|
|
"AND trim(component_manufacturer_serial_snapshot) <> '' "
|
|
"AND char_length(component_manufacturer_serial_snapshot) <= 255)",
|
|
name="ck_repair_parts_serial_snapshot",
|
|
),
|
|
sa.CheckConstraint(
|
|
"notes IS NULL OR (notes = trim(notes) AND trim(notes) <> '' "
|
|
"AND char_length(notes) <= 4000)",
|
|
name="ck_repair_parts_notes",
|
|
),
|
|
sa.CheckConstraint(
|
|
"(voided_by IS NULL AND voided_at IS NULL AND void_reason IS NULL "
|
|
"AND void_event_id IS NULL) OR "
|
|
"(voided_by IS NOT NULL AND voided_at IS NOT NULL "
|
|
"AND void_reason IS NOT NULL AND void_event_id IS NOT NULL)",
|
|
name="ck_repair_parts_void_state",
|
|
),
|
|
sa.CheckConstraint(
|
|
"void_reason IS NULL OR (void_reason = trim(void_reason) "
|
|
"AND trim(void_reason) <> '' AND char_length(void_reason) <= 1000)",
|
|
name="ck_repair_parts_void_reason",
|
|
),
|
|
sa.CheckConstraint(
|
|
"voided_at IS NULL OR voided_at >= recorded_at",
|
|
name="ck_repair_parts_void_chronology",
|
|
),
|
|
)
|
|
op.create_index(
|
|
"idx_repair_parts_repair",
|
|
"repair_parts",
|
|
["repair_id", "recorded_event_id", "id"],
|
|
)
|
|
op.create_index("idx_repair_parts_component", "repair_parts", ["component_id"])
|
|
op.create_index("idx_repair_parts_recorded_by", "repair_parts", ["recorded_by"])
|
|
op.create_index("idx_repair_parts_voided_by", "repair_parts", ["voided_by"])
|
|
op.create_index(
|
|
"uq_repair_parts_recorded_event",
|
|
"repair_parts",
|
|
["recorded_event_id"],
|
|
unique=True,
|
|
)
|
|
op.create_index(
|
|
"uq_repair_parts_void_event",
|
|
"repair_parts",
|
|
["void_event_id"],
|
|
unique=True,
|
|
postgresql_where=sa.text("void_event_id IS NOT NULL"),
|
|
)
|
|
|
|
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
|
|
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
|
|
_create_event_constraints(True)
|
|
|
|
connection.exec_driver_sql(
|
|
"""
|
|
CREATE FUNCTION minerops_protect_component_identity()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
RAISE EXCEPTION 'Durable component identity cannot be deleted';
|
|
END IF;
|
|
IF NEW.id <> OLD.id
|
|
OR NEW.component_type <> OLD.component_type
|
|
OR NEW.created_at <> OLD.created_at
|
|
OR NEW.created_by <> OLD.created_by THEN
|
|
RAISE EXCEPTION 'Component identity, type, and creation provenance are immutable';
|
|
END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
CREATE TRIGGER trg_protect_component_identity
|
|
BEFORE UPDATE OR DELETE ON components
|
|
FOR EACH ROW EXECUTE FUNCTION minerops_protect_component_identity();
|
|
|
|
CREATE FUNCTION minerops_protect_repair_event_history()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
RAISE EXCEPTION 'Repair event history is immutable';
|
|
END $$;
|
|
CREATE TRIGGER trg_protect_repair_event_history
|
|
BEFORE UPDATE OR DELETE ON repair_events
|
|
FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_event_history();
|
|
|
|
CREATE FUNCTION minerops_protect_repair_part_history()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
RAISE EXCEPTION 'Repair-part history is immutable';
|
|
END IF;
|
|
IF NEW.id <> OLD.id
|
|
OR NEW.repair_id <> OLD.repair_id
|
|
OR NEW.consumable_description IS DISTINCT FROM OLD.consumable_description
|
|
OR NEW.quantity IS DISTINCT FROM OLD.quantity
|
|
OR NEW.unit IS DISTINCT FROM OLD.unit
|
|
OR NEW.component_id IS DISTINCT FROM OLD.component_id
|
|
OR NEW.component_type_snapshot IS DISTINCT FROM OLD.component_type_snapshot
|
|
OR NEW.component_asset_id_snapshot IS DISTINCT FROM OLD.component_asset_id_snapshot
|
|
OR NEW.component_manufacturer_snapshot IS DISTINCT FROM OLD.component_manufacturer_snapshot
|
|
OR NEW.component_model_snapshot IS DISTINCT FROM OLD.component_model_snapshot
|
|
OR NEW.component_manufacturer_serial_snapshot IS DISTINCT FROM OLD.component_manufacturer_serial_snapshot
|
|
OR NEW.notes IS DISTINCT FROM OLD.notes
|
|
OR NEW.recorded_by <> OLD.recorded_by
|
|
OR NEW.recorded_at <> OLD.recorded_at
|
|
OR NEW.recorded_event_id <> OLD.recorded_event_id THEN
|
|
RAISE EXCEPTION 'Original repair-part fields are immutable';
|
|
END IF;
|
|
IF OLD.voided_by IS NOT NULL OR OLD.voided_at IS NOT NULL
|
|
OR OLD.void_reason IS NOT NULL OR OLD.void_event_id IS NOT NULL THEN
|
|
RAISE EXCEPTION 'Voided repair-part history is immutable';
|
|
END IF;
|
|
IF NEW.voided_by IS NULL OR NEW.voided_at IS NULL
|
|
OR NEW.void_reason IS NULL OR NEW.void_event_id IS NULL THEN
|
|
RAISE EXCEPTION 'Repair-part void metadata must be written atomically';
|
|
END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
CREATE TRIGGER trg_protect_repair_part_history
|
|
BEFORE UPDATE OR DELETE ON repair_parts
|
|
FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_part_history();
|
|
|
|
CREATE FUNCTION minerops_validate_repair_part_history()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
DECLARE recorded repair_events%%ROWTYPE;
|
|
DECLARE voided repair_events%%ROWTYPE;
|
|
DECLARE component components%%ROWTYPE;
|
|
BEGIN
|
|
IF TG_OP = 'INSERT' AND (
|
|
NEW.voided_by IS NOT NULL OR NEW.voided_at IS NOT NULL
|
|
OR NEW.void_reason IS NOT NULL OR NEW.void_event_id IS NOT NULL) THEN
|
|
RAISE EXCEPTION 'A repair part must be recorded before it can be voided';
|
|
END IF;
|
|
SELECT * INTO recorded FROM repair_events WHERE id = NEW.recorded_event_id;
|
|
IF recorded.id IS NULL
|
|
OR recorded.repair_id <> NEW.repair_id
|
|
OR recorded.event_type <> 'REPAIR_PART_RECORDED'
|
|
OR recorded.created_by <> NEW.recorded_by
|
|
OR recorded.created_at <> NEW.recorded_at
|
|
OR recorded.payload ->> 'repair_part_id' <> NEW.id::text THEN
|
|
RAISE EXCEPTION 'Recorded repair-part event does not match its entry';
|
|
END IF;
|
|
|
|
IF TG_OP = 'INSERT' AND NEW.component_id IS NOT NULL THEN
|
|
SELECT * INTO component FROM components WHERE id = NEW.component_id;
|
|
IF component.id IS NULL
|
|
OR component.component_type <> NEW.component_type_snapshot
|
|
OR component.asset_id IS DISTINCT FROM NEW.component_asset_id_snapshot
|
|
OR component.manufacturer IS DISTINCT FROM NEW.component_manufacturer_snapshot
|
|
OR component.model IS DISTINCT FROM NEW.component_model_snapshot
|
|
OR component.manufacturer_serial IS DISTINCT FROM NEW.component_manufacturer_serial_snapshot THEN
|
|
RAISE EXCEPTION 'Tracked-component snapshot does not match the component';
|
|
END IF;
|
|
END IF;
|
|
|
|
IF NEW.void_event_id IS NOT NULL THEN
|
|
SELECT * INTO voided FROM repair_events WHERE id = NEW.void_event_id;
|
|
IF voided.id IS NULL
|
|
OR voided.repair_id <> NEW.repair_id
|
|
OR voided.event_type <> 'REPAIR_PART_VOIDED'
|
|
OR voided.created_by <> NEW.voided_by
|
|
OR voided.created_at <> NEW.voided_at
|
|
OR voided.payload ->> 'repair_part_id' <> NEW.id::text
|
|
OR voided.payload ->> 'reason' <> NEW.void_reason THEN
|
|
RAISE EXCEPTION 'Voided repair-part event does not match its entry';
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
CREATE TRIGGER trg_validate_repair_part_history
|
|
BEFORE INSERT OR UPDATE ON repair_parts
|
|
FOR EACH ROW EXECUTE FUNCTION minerops_validate_repair_part_history();
|
|
"""
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
connection = op.get_bind()
|
|
connection.exec_driver_sql(
|
|
"""
|
|
DO $$
|
|
DECLARE history_count bigint;
|
|
BEGIN
|
|
SELECT count(*) INTO history_count FROM components;
|
|
history_count := history_count + (SELECT count(*) FROM repair_parts);
|
|
history_count := history_count + (
|
|
SELECT count(*) FROM repair_events
|
|
WHERE event_type IN ('REPAIR_PART_RECORDED', 'REPAIR_PART_VOIDED')
|
|
);
|
|
IF history_count > 0 THEN
|
|
RAISE EXCEPTION USING
|
|
MESSAGE = format(
|
|
'Cannot downgrade Repair Parts Tracking: %s component, repair-part, or event records would be destroyed',
|
|
history_count
|
|
),
|
|
HINT = 'Preserve or explicitly migrate Task 005 identity and history before downgrading; MinerOps will not delete or invent component, actor, timestamp, snapshot, or repair-part history.';
|
|
END IF;
|
|
END $$;
|
|
"""
|
|
)
|
|
|
|
connection.exec_driver_sql(
|
|
"""
|
|
DROP TRIGGER trg_validate_repair_part_history ON repair_parts;
|
|
DROP FUNCTION minerops_validate_repair_part_history();
|
|
DROP TRIGGER trg_protect_repair_part_history ON repair_parts;
|
|
DROP FUNCTION minerops_protect_repair_part_history();
|
|
DROP TRIGGER trg_protect_repair_event_history ON repair_events;
|
|
DROP FUNCTION minerops_protect_repair_event_history();
|
|
DROP TRIGGER trg_protect_component_identity ON components;
|
|
DROP FUNCTION minerops_protect_component_identity();
|
|
"""
|
|
)
|
|
|
|
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
|
|
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
|
|
_create_event_constraints(False)
|
|
|
|
op.drop_index("uq_repair_parts_void_event", table_name="repair_parts")
|
|
op.drop_index("uq_repair_parts_recorded_event", table_name="repair_parts")
|
|
op.drop_index("idx_repair_parts_voided_by", table_name="repair_parts")
|
|
op.drop_index("idx_repair_parts_recorded_by", table_name="repair_parts")
|
|
op.drop_index("idx_repair_parts_component", table_name="repair_parts")
|
|
op.drop_index("idx_repair_parts_repair", table_name="repair_parts")
|
|
op.drop_table("repair_parts")
|
|
|
|
op.drop_index("uq_components_normalized_asset_id", table_name="components")
|
|
op.drop_index("idx_components_type", table_name="components")
|
|
op.drop_index("idx_components_created_by", table_name="components")
|
|
op.drop_table("components")
|
|
|
|
connection.exec_driver_sql(
|
|
"""
|
|
DELETE FROM role_permissions
|
|
WHERE permission_id IN (
|
|
SELECT id FROM permissions
|
|
WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage')
|
|
);
|
|
DELETE FROM permissions
|
|
WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage');
|
|
"""
|
|
)
|