diff --git a/api-v3/src/database/migrations/1790000600000-f4-align-document-lifecycle-constraints.ts b/api-v3/src/database/migrations/1790000600000-f4-align-document-lifecycle-constraints.ts new file mode 100644 index 0000000..4800de9 --- /dev/null +++ b/api-v3/src/database/migrations/1790000600000-f4-align-document-lifecycle-constraints.ts @@ -0,0 +1,205 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Alinea las restricciones físicas heredadas de D5 con el ciclo documental F4. + * + * F4 incorpora estados LOCKED/SEALED para Actas y WORKING/OFFICIALIZED para INF. + * Las migraciones históricas conservaban CHECKs y triggers de READY/CLOSED/FROZEN, + * por lo que el servicio podía compilar pero PostgreSQL rechazaba las transiciones. + */ +export class F4AlignDocumentLifecycleConstraints1790000600000 implements MigrationInterface { + name = 'F4AlignDocumentLifecycleConstraints1790000600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_reports + DROP CONSTRAINT IF EXISTS chk_inspection_reports_status + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD CONSTRAINT chk_inspection_reports_status CHECK ( + status IN ('WORKING','OFFICIALIZED','FROZEN','CANCELLED') + ) + `); + + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_status + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_status CHECK ( + status IN ('DRAFT','LOCKED','SEALED','READY','CLOSED','CANCELLED','RECTIFIED') + ) + `); + + await queryRunner.query(` + ALTER TABLE inspection_act_versions + DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event + `); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + ADD CONSTRAINT chk_inspection_act_versions_event CHECK ( + event IN ('CREATED','UPDATED','LOCKED','SEALED','READY','REOPENED','CLOSED','CANCELLED') + ) + `); + + // El cierre físico F4 se representa como SEALED. Conservamos CLOSED como + // compatibilidad histórica y exigimos la misma integridad en ambos estados. + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_closure CHECK ( + status NOT IN ('SEALED','CLOSED') + OR ( + closed_at IS NOT NULL + AND closed_by IS NOT NULL + AND closure_sha256 ~ '^[0-9a-f]{64}$' + ) + ) + `); + + // Las firmas F4 pertenecen al snapshot LOCKED. El trigger D5 exigía READY. + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE current_status varchar(24); + DECLARE current_sha char(64); + BEGIN + SELECT act.status, closure.prepared_sha256 + INTO current_status, current_sha + FROM inspection_acts act + INNER JOIN inspection_act_closures closure ON closure.act_id = act.id + WHERE act.id = NEW.act_id; + IF current_status <> 'LOCKED' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN + RAISE EXCEPTION 'La firma no corresponde a un acta bloqueada vigente'; + END IF; + RETURN NEW; + END + $$ + `); + + // SEALED es tan inmutable como CLOSED/RECTIFIED en el contrato histórico. + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF OLD.status IN ('SEALED','CLOSED','RECTIFIED') THEN + RAISE EXCEPTION 'El acta sellada es inmutable'; + END IF; + RETURN NEW; + END + $$ + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // El contrato D5 no conoce los estados F4. Reducimos las representaciones + // nuevas a sus equivalentes históricos antes de restaurar sus CHECKs. + await queryRunner.query(` + UPDATE inspection_reports + SET status='FROZEN' + WHERE status IN ('WORKING','OFFICIALIZED') + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + DROP CONSTRAINT IF EXISTS chk_inspection_reports_status + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD CONSTRAINT chk_inspection_reports_status CHECK ( + status IN ('FROZEN','CANCELLED') + ) + `); + + await queryRunner.query(` + UPDATE inspection_acts + SET status=CASE + WHEN status='LOCKED' THEN 'READY' + WHEN status='SEALED' THEN 'CLOSED' + ELSE status + END + WHERE status IN ('LOCKED','SEALED') + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_status + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_status CHECK ( + status IN ('DRAFT','READY','CLOSED','CANCELLED','RECTIFIED') + ) + `); + + await queryRunner.query(` + UPDATE inspection_act_versions + SET event=CASE + WHEN event='LOCKED' THEN 'READY' + WHEN event='SEALED' THEN 'CLOSED' + ELSE event + END + WHERE event IN ('LOCKED','SEALED') + `); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event + `); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + ADD CONSTRAINT chk_inspection_act_versions_event CHECK ( + event IN ('CREATED','UPDATED','READY','REOPENED','CLOSED','CANCELLED') + ) + `); + + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_closure CHECK ( + status <> 'CLOSED' + OR ( + closed_at IS NOT NULL + AND closed_by IS NOT NULL + AND closure_sha256 ~ '^[0-9a-f]{64}$' + ) + ) + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE current_status varchar(24); + DECLARE current_sha char(64); + BEGIN + SELECT act.status, closure.prepared_sha256 + INTO current_status, current_sha + FROM inspection_acts act + INNER JOIN inspection_act_closures closure ON closure.act_id = act.id + WHERE act.id = NEW.act_id; + IF current_status <> 'READY' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN + RAISE EXCEPTION 'La firma no corresponde a un acta preparada vigente'; + END IF; + RETURN NEW; + END + $$ + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF OLD.status IN ('CLOSED','RECTIFIED') THEN + RAISE EXCEPTION 'El acta cerrada es inmutable'; + END IF; + RETURN NEW; + END + $$ + `); + } +}