-- ARR daily PRICE_UNMATCHED manual-price review. -- Apply only after the operator has confirmed/applied migration 016 in the same -- release window. This migration is additive and records no guest information -- in review tables beyond the normalized pricing key and aggregate impact. BEGIN; DO $$ BEGIN IF current_database() <> 'booking_test' THEN RAISE EXCEPTION 'ARR daily price review migration is allowed only in booking_test'; END IF; IF to_regclass('ingestion.processing_runs') IS NULL OR to_regclass('ingestion.processing_attempts') IS NULL OR to_regclass('ingestion.processing_deliveries') IS NULL OR to_regclass('finance.daily_versions') IS NULL OR to_regclass('finance.daily_records') IS NULL OR to_regprocedure('reporting.validate_monthly_run_publication()') IS NULL THEN RAISE EXCEPTION 'ARR migrations 008 through 016 must be applied before 017'; END IF; IF to_regclass('ingestion.daily_review_cases') IS NOT NULL THEN RAISE EXCEPTION 'ARR daily price review migration is already applied'; END IF; END; $$; -- New lifecycle values. Historical constraints are replaced verbatim rather -- than modifying migrations 008/010. ALTER TABLE ingestion.processing_runs DROP CONSTRAINT IF EXISTS processing_runs_run_status_check, DROP CONSTRAINT IF EXISTS processing_runs_terminal_shape; ALTER TABLE ingestion.processing_runs ADD CONSTRAINT processing_runs_run_status_check CHECK (run_status IN ( 'received', 'queued', 'running', 'validating', 'awaiting_review', 'accepted', 'rejected', 'failed', 'cancelled' )), ADD CONSTRAINT processing_runs_terminal_shape CHECK ( ( run_status = 'accepted' AND failure_code IS NULL AND validated_at IS NOT NULL AND finished_at IS NOT NULL AND ( pipeline_type <> 'opera_daily' OR ( business_date IS NOT NULL AND delivered_processor_version IS NOT NULL AND delivered_rule_set_sha256 IS NOT NULL AND result_schema_version IS NOT NULL AND delivery_sha256 IS NOT NULL AND ( ( result_delivery_mode = 'artifact_callback' AND result_artifact_id IS NOT NULL ) OR ( result_delivery_mode = 'direct_mcp' AND result_artifact_id IS NULL ) ) ) ) ) OR ( run_status IN ('rejected', 'failed') AND failure_code IS NOT NULL AND finished_at IS NOT NULL ) OR (run_status = 'cancelled' AND finished_at IS NOT NULL) OR ( run_status = 'awaiting_review' AND pipeline_type = 'opera_daily' AND result_delivery_mode = 'artifact_callback' AND result_artifact_id IS NOT NULL AND business_date IS NOT NULL AND delivered_processor_version IS NOT NULL AND delivered_rule_set_sha256 IS NOT NULL AND result_schema_version = '4.0' AND delivery_sha256 IS NOT NULL AND validated_at IS NOT NULL AND finished_at IS NULL ) OR run_status IN ('received', 'queued', 'running', 'validating') ); ALTER TABLE ingestion.processing_attempts DROP CONSTRAINT IF EXISTS processing_attempts_attempt_status_check, DROP CONSTRAINT IF EXISTS processing_attempts_terminal_shape; ALTER TABLE ingestion.processing_attempts ADD CONSTRAINT processing_attempts_attempt_status_check CHECK (attempt_status IN ( 'queued', 'dispatched', 'running', 'delivered', 'review_required', 'succeeded', 'failed', 'cancelled' )), ADD CONSTRAINT processing_attempts_terminal_shape CHECK ( ( attempt_status = 'succeeded' AND failure_code IS NULL AND finished_at IS NOT NULL ) OR ( attempt_status = 'failed' AND failure_code IS NOT NULL AND finished_at IS NOT NULL ) OR ( attempt_status = 'review_required' AND failure_code IS NULL AND finished_at IS NOT NULL ) OR (attempt_status = 'cancelled' AND finished_at IS NOT NULL) OR attempt_status IN ('queued', 'dispatched', 'running', 'delivered') ); ALTER TABLE ingestion.processing_deliveries ADD COLUMN manual_override_artifact_id bigint REFERENCES ingestion.artifacts(id), ADD COLUMN review_case_id bigint, DROP CONSTRAINT IF EXISTS processing_deliveries_delivery_status_check, DROP CONSTRAINT IF EXISTS processing_deliveries_result_status_check; ALTER TABLE ingestion.processing_deliveries ADD CONSTRAINT processing_deliveries_delivery_status_check CHECK (delivery_status IN ( 'received', 'validating', 'committed', 'recorded_failure', 'recorded_review', 'rejected' )), ADD CONSTRAINT processing_deliveries_result_status_check CHECK (result_status IN ( 'success', 'review_required', 'failed' )); CREATE TABLE ingestion.daily_review_cases ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, case_key text NOT NULL UNIQUE CHECK ( case_key ~ '^dailyreview-[0-9a-f]{32}$' ), processing_run_id bigint NOT NULL UNIQUE REFERENCES ingestion.processing_runs(id), initial_delivery_id bigint NOT NULL UNIQUE REFERENCES ingestion.processing_deliveries(id), business_date date NOT NULL, source_sha256 character(64) NOT NULL CHECK ( source_sha256 ~ '^[0-9a-f]{64}$' ), processor_version text NOT NULL CHECK (btrim(processor_version) <> ''), rule_set_sha256 character(64) NOT NULL CHECK ( rule_set_sha256 ~ '^[0-9a-f]{64}$' ), review_version text NOT NULL CHECK (review_version = '1.0'), case_status text NOT NULL CHECK (case_status IN ( 'open', 'processing', 'completed', 'generation_failed', 'failed', 'cancelled' )), revision integer NOT NULL DEFAULT 0 CHECK (revision >= 0), manual_override_json jsonb CHECK ( manual_override_json IS NULL OR jsonb_typeof(manual_override_json) = 'object' ), manual_override_sha256 character(64) CHECK ( manual_override_sha256 IS NULL OR manual_override_sha256 ~ '^[0-9a-f]{64}$' ), manual_override_artifact_id bigint REFERENCES ingestion.artifacts(id), frozen_at timestamptz, failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), completed_at timestamptz, cancelled_at timestamptz, CONSTRAINT daily_review_cases_frozen_shape CHECK ( (frozen_at IS NULL AND manual_override_json IS NULL AND manual_override_sha256 IS NULL AND manual_override_artifact_id IS NULL) OR (frozen_at IS NOT NULL AND manual_override_json IS NOT NULL AND manual_override_sha256 IS NOT NULL) ), CONSTRAINT daily_review_cases_terminal_shape CHECK ( (case_status = 'completed' AND completed_at IS NOT NULL AND frozen_at IS NOT NULL) OR (case_status = 'cancelled' AND cancelled_at IS NOT NULL) OR case_status IN ('open', 'processing', 'generation_failed', 'failed') ) ); CREATE INDEX daily_review_cases_open_idx ON ingestion.daily_review_cases (case_status, updated_at) WHERE case_status IN ('open', 'generation_failed', 'processing'); CREATE TABLE ingestion.daily_review_items ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, review_case_id bigint NOT NULL REFERENCES ingestion.daily_review_cases(id), company_key text NOT NULL CHECK (btrim(company_key) <> ''), rate_code text NOT NULL CHECK ( rate_code = upper(btrim(rate_code)) AND rate_code ~ '^[A-Z0-9]+$' ), effective_rate_amount numeric(18,2) NOT NULL CHECK (effective_rate_amount >= 0), candidate_prices jsonb NOT NULL DEFAULT '[]'::jsonb CHECK ( jsonb_typeof(candidate_prices) = 'array' ), affected_records integer NOT NULL CHECK (affected_records > 0), affected_rooms integer NOT NULL CHECK (affected_rooms > 0), affected_room_nights integer NOT NULL CHECK (affected_room_nights >= 0), real_price numeric(18,2) CHECK (real_price >= 0), revision integer NOT NULL DEFAULT 0 CHECK (revision >= 0), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT daily_review_items_key_unique UNIQUE (review_case_id, company_key, rate_code, effective_rate_amount) ); CREATE INDEX daily_review_items_case_idx ON ingestion.daily_review_items (review_case_id, id); ALTER TABLE ingestion.daily_review_items ADD CONSTRAINT daily_review_items_id_case_unique UNIQUE (id, review_case_id); CREATE TABLE ingestion.daily_review_events ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, review_case_id bigint NOT NULL REFERENCES ingestion.daily_review_cases(id), review_item_id bigint REFERENCES ingestion.daily_review_items(id), event_type text NOT NULL CHECK (event_type IN ( 'PRICE_REVIEW_REQUIRED', 'PRICE_REVIEW_UPDATED', 'PRICE_REVIEW_FINALIZED', 'PRICE_REVIEW_CANCELLED', 'PRICE_REVIEW_GENERATION_FAILED', 'PRICE_REVIEW_FAILED' )), actor_username text NOT NULL CHECK (btrim(actor_username) <> ''), previous_real_price numeric(18,2) CHECK (previous_real_price >= 0), new_real_price numeric(18,2) CHECK (new_real_price >= 0), revision integer NOT NULL CHECK (revision >= 0), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT daily_review_events_item_case_fk FOREIGN KEY (review_item_id, review_case_id) REFERENCES ingestion.daily_review_items(id, review_case_id) ); ALTER TABLE ingestion.processing_deliveries ADD CONSTRAINT processing_deliveries_review_case_fk FOREIGN KEY (review_case_id) REFERENCES ingestion.daily_review_cases(id); CREATE OR REPLACE FUNCTION ingestion.enforce_daily_review_item_mutability() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE locked_case_status text; locked_case_id bigint; BEGIN locked_case_id := COALESCE(NEW.review_case_id, OLD.review_case_id); SELECT case_status INTO locked_case_status FROM ingestion.daily_review_cases WHERE id = locked_case_id FOR KEY SHARE; IF locked_case_status IS NULL THEN RAISE EXCEPTION 'daily review case is unavailable'; END IF; IF locked_case_status <> 'open' THEN RAISE EXCEPTION 'daily review items are immutable after final confirmation'; END IF; IF TG_OP = 'UPDATE' AND ( NEW.review_case_id <> OLD.review_case_id OR NEW.company_key <> OLD.company_key OR NEW.rate_code <> OLD.rate_code OR NEW.effective_rate_amount <> OLD.effective_rate_amount OR NEW.candidate_prices <> OLD.candidate_prices OR NEW.affected_records <> OLD.affected_records OR NEW.affected_rooms <> OLD.affected_rooms OR NEW.affected_room_nights <> OLD.affected_room_nights ) THEN RAISE EXCEPTION 'daily review pricing key and impact are immutable'; END IF; IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; $$; CREATE TRIGGER daily_review_items_mutability_guard BEFORE UPDATE OR DELETE ON ingestion.daily_review_items FOR EACH ROW EXECUTE FUNCTION ingestion.enforce_daily_review_item_mutability(); CREATE OR REPLACE FUNCTION ingestion.enforce_daily_review_case_immutability() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'daily review cases are permanent audit records'; END IF; IF OLD.frozen_at IS NOT NULL AND ( NEW.manual_override_json IS DISTINCT FROM OLD.manual_override_json OR NEW.manual_override_sha256 IS DISTINCT FROM OLD.manual_override_sha256 OR ( OLD.manual_override_artifact_id IS NOT NULL AND NEW.manual_override_artifact_id IS DISTINCT FROM OLD.manual_override_artifact_id ) OR NEW.source_sha256 IS DISTINCT FROM OLD.source_sha256 OR NEW.business_date IS DISTINCT FROM OLD.business_date OR NEW.processor_version IS DISTINCT FROM OLD.processor_version OR NEW.rule_set_sha256 IS DISTINCT FROM OLD.rule_set_sha256 OR NEW.review_version IS DISTINCT FROM OLD.review_version ) THEN RAISE EXCEPTION 'frozen manual override provenance is immutable'; END IF; RETURN NEW; END; $$; CREATE TRIGGER daily_review_cases_immutability_guard BEFORE UPDATE OR DELETE ON ingestion.daily_review_cases FOR EACH ROW EXECUTE FUNCTION ingestion.enforce_daily_review_case_immutability(); CREATE OR REPLACE FUNCTION ingestion.enforce_daily_review_event_immutability() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'daily review events are append-only audit records'; END; $$; CREATE TRIGGER daily_review_events_immutability_guard BEFORE UPDATE OR DELETE ON ingestion.daily_review_events FOR EACH ROW EXECUTE FUNCTION ingestion.enforce_daily_review_event_immutability(); ALTER TABLE finance.daily_versions ADD COLUMN review_case_id bigint REFERENCES ingestion.daily_review_cases(id), ADD COLUMN manual_override_sha256 character(64) CHECK ( manual_override_sha256 IS NULL OR manual_override_sha256 ~ '^[0-9a-f]{64}$' ), ADD COLUMN manually_priced_rows integer NOT NULL DEFAULT 0 CHECK (manually_priced_rows >= 0), ADD CONSTRAINT daily_versions_review_lineage_shape CHECK ( (review_case_id IS NULL AND manual_override_sha256 IS NULL AND manually_priced_rows = 0) OR (review_case_id IS NOT NULL AND manual_override_sha256 IS NOT NULL AND manually_priced_rows > 0) ); CREATE UNIQUE INDEX daily_versions_review_case_unique ON finance.daily_versions (review_case_id) WHERE review_case_id IS NOT NULL; DROP INDEX IF EXISTS finance.daily_versions_source_rule_unique; CREATE UNIQUE INDEX daily_versions_source_rule_override_unique ON finance.daily_versions ( source_artifact_id, business_date, processor_version, rule_set_sha256, COALESCE(manual_override_sha256, '') ) WHERE business_date IS NOT NULL; ALTER TABLE finance.daily_records DROP CONSTRAINT IF EXISTS daily_records_retained_values; ALTER TABLE finance.daily_records ADD CONSTRAINT daily_records_retained_values CHECK ( outcome <> 'retained' OR ( block_code IS NOT NULL AND adults IS NOT NULL AND adults >= 0 AND children IS NOT NULL AND children >= 0 AND company_name IS NOT NULL AND btrim(company_name) <> '' AND company_key IS NOT NULL AND btrim(company_key) <> '' AND confirmation_no IS NOT NULL AND btrim(confirmation_no) <> '' AND disp_room_no IS NOT NULL AND btrim(disp_room_no) <> '' AND effective_rate_amount IS NOT NULL AND effective_rate_amount >= 0 AND full_name IS NOT NULL AND no_of_rooms IS NOT NULL AND no_of_rooms > 0 AND rate_code IS NOT NULL AND normalized_rate_code IS NOT NULL AND arrival IS NOT NULL AND departure IS NOT NULL AND nights IS NOT NULL AND nights >= 0 AND real_price IS NOT NULL AND real_price >= 0 AND total_price IS NOT NULL AND total_price >= 0 AND channel_key IS NOT NULL AND btrim(channel_key) <> '' AND pricing_method IN ( 'price_reference_exact', 'zero_price_exception', 'manual_review' ) ) ); COMMENT ON TABLE ingestion.daily_review_cases IS 'One immutable-audit manual price review case per XML processing run. No guest names, comments, traces or raw rows are stored here.'; COMMENT ON TABLE ingestion.daily_review_items IS 'One staff-editable non-negative price per normalized missing fixed-price key; mutable only while its case is open.'; COMMENT ON TABLE ingestion.daily_review_events IS 'Append-only actor, revision and before/after-price audit history. Intentionally has no notes field.'; COMMENT ON COLUMN finance.daily_versions.manual_override_sha256 IS 'SHA-256 of the canonical frozen review manifest used for independently validated final replay.'; -- The application needs no DELETE privilege over review facts. Deployments that -- use a differently named login role keep their existing grant model unchanged. DO $$ BEGIN IF to_regrole('arr_app') IS NOT NULL THEN EXECUTE 'GRANT SELECT, INSERT, UPDATE ON ingestion.daily_review_cases, ingestion.daily_review_items TO arr_app'; EXECUTE 'GRANT SELECT, INSERT ON ingestion.daily_review_events TO arr_app'; EXECUTE 'GRANT USAGE, SELECT ON SEQUENCE ingestion.daily_review_cases_id_seq, ingestion.daily_review_items_id_seq, ingestion.daily_review_events_id_seq TO arr_app'; END IF; END; $$; COMMIT;