402 lines
17 KiB
PL/PgSQL
402 lines
17 KiB
PL/PgSQL
-- 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;
|