Files
wyndham-ARR/database/017_daily_price_review.sql
2026-08-06 22:40:18 +08:00

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;