-- ARR database MVP v1: OSS/Agent ingestion, booking source facts, and Finance daily facts -- PostgreSQL 15+ -- Destructive only inside the isolated booking_test database. -- Apply only through the guarded executor after a verified backup and rollback probe. BEGIN; DO $$ BEGIN IF current_database() <> 'booking_test' THEN RAISE EXCEPTION 'ARR MVP v1 rebuild is allowed only in booking_test'; END IF; END; $$; DROP SCHEMA IF EXISTS finance CASCADE; DROP SCHEMA IF EXISTS booking CASCADE; DROP SCHEMA IF EXISTS ingestion CASCADE; CREATE SCHEMA ingestion; CREATE SCHEMA booking; CREATE SCHEMA finance; COMMENT ON SCHEMA ingestion IS 'ARR-owned OSS artifact identities, Agent processing, delivery validation, and reliable events.'; COMMENT ON SCHEMA booking IS 'Booking source rows, immutable row-level Agent parses, and queryable original room items.'; COMMENT ON SCHEMA finance IS 'Opera daily result sets and records used by ordinary programs to generate monthly and channel outputs.'; -- --------------------------------------------------------------------------- -- Cross-domain ingestion -- --------------------------------------------------------------------------- CREATE TABLE ingestion.artifacts ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, artifact_kind text NOT NULL CHECK (artifact_kind IN ( 'booking_source_md', 'booking_excel', 'opera_xml', 'daily_xlsx', 'monthly_xlsx', 'channel_detail_xlsx', 'company_ten_day_xlsx', 'exception_xlsx', 'result_json', 'structured_result_json' )), storage_provider text NOT NULL CHECK (storage_provider IN ( 'oss', 's3', 'local_fixture' )), bucket_alias text NOT NULL CHECK ( btrim(bucket_alias) <> '' AND bucket_alias !~ '[/\\]' ), object_key text NOT NULL CHECK ( btrim(object_key) <> '' AND object_key !~ '^/' AND object_key !~ '(^|/)\.\.(/|$)' AND object_key !~ '[?#]' ), object_version_id text, etag text, original_filename text NOT NULL CHECK (btrim(original_filename) <> ''), sha256 character(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), byte_size bigint NOT NULL CHECK (byte_size >= 0), mime_type text, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT artifacts_kind_hash_unique UNIQUE (artifact_kind, sha256) ); CREATE UNIQUE INDEX artifacts_storage_identity_unique ON ingestion.artifacts ( storage_provider, bucket_alias, object_key, COALESCE(object_version_id, '') ); COMMENT ON TABLE ingestion.artifacts IS 'Metadata-only identity for immutable private objects. Never stores bytes, signed URLs, access keys, or Agent-local paths.'; CREATE TABLE ingestion.processing_runs ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, run_key text NOT NULL UNIQUE CHECK ( run_key ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' ), pipeline_type text NOT NULL CHECK (pipeline_type IN ( 'opera_daily', 'booking_source_import' )), source_artifact_id bigint NOT NULL REFERENCES ingestion.artifacts(id), result_artifact_id bigint REFERENCES ingestion.artifacts(id), run_status text NOT NULL CHECK (run_status IN ( 'received', 'queued', 'running', 'validating', 'accepted', 'rejected', 'failed', 'cancelled' )), requested_processor_version text, requested_rule_set_sha256 character(64) CHECK ( requested_rule_set_sha256 IS NULL OR requested_rule_set_sha256 ~ '^[0-9a-f]{64}$' ), delivered_processor_version text, delivered_rule_set_sha256 character(64) CHECK ( delivered_rule_set_sha256 IS NULL OR delivered_rule_set_sha256 ~ '^[0-9a-f]{64}$' ), result_schema_version text, delivery_sha256 character(64) CHECK ( delivery_sha256 IS NULL OR delivery_sha256 ~ '^[0-9a-f]{64}$' ), delivery_json jsonb NOT NULL DEFAULT '{}'::jsonb CHECK ( jsonb_typeof(delivery_json) = 'object' ), business_date date, failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), validated_at timestamptz, finished_at timestamptz, 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 ( 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 IS NOT NULL AND delivery_sha256 IS NOT 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 IN ('received', 'queued', 'running', 'validating') ) ); CREATE TABLE ingestion.processing_attempts ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, processing_run_id bigint NOT NULL REFERENCES ingestion.processing_runs(id), attempt_no integer NOT NULL CHECK (attempt_no > 0), attempt_status text NOT NULL CHECK (attempt_status IN ( 'queued', 'dispatched', 'running', 'delivered', 'succeeded', 'failed', 'cancelled' )), idempotency_key character(64) NOT NULL UNIQUE CHECK ( idempotency_key ~ '^[0-9a-f]{64}$' ), remote_run_id text UNIQUE, failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), started_at timestamptz, finished_at timestamptz, CONSTRAINT processing_attempts_run_number_unique UNIQUE (processing_run_id, attempt_no), CONSTRAINT processing_attempts_id_run_unique UNIQUE (id, processing_run_id), 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 = 'cancelled' AND finished_at IS NOT NULL ) OR attempt_status IN ('queued', 'dispatched', 'running', 'delivered') ) ); CREATE TABLE ingestion.processing_deliveries ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, delivery_key text NOT NULL UNIQUE CHECK ( delivery_key ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' ), processing_run_id bigint NOT NULL REFERENCES ingestion.processing_runs(id), attempt_id bigint, envelope_sha256 character(64) NOT NULL CHECK ( envelope_sha256 ~ '^[0-9a-f]{64}$' ), envelope_json jsonb NOT NULL CHECK ( jsonb_typeof(envelope_json) = 'object' ), delivery_status text NOT NULL CHECK (delivery_status IN ( 'received', 'validating', 'committed', 'recorded_failure', 'rejected' )), result_status text NOT NULL CHECK (result_status IN ( 'success', 'failed' )), processor_version text NOT NULL CHECK (btrim(processor_version) <> ''), rule_set_sha256 character(64) NOT NULL CHECK ( rule_set_sha256 ~ '^[0-9a-f]{64}$' ), result_schema_version text NOT NULL CHECK ( btrim(result_schema_version) <> '' ), business_date date, source_artifact_id bigint REFERENCES ingestion.artifacts(id), daily_report_artifact_id bigint REFERENCES ingestion.artifacts(id), result_json_artifact_id bigint REFERENCES ingestion.artifacts(id), structured_result_artifact_id bigint REFERENCES ingestion.artifacts(id), exception_report_artifact_id bigint REFERENCES ingestion.artifacts(id), daily_version_id bigint, failure_code text, failure_message text, received_at timestamptz NOT NULL DEFAULT now(), validated_at timestamptz, committed_at timestamptz, CONSTRAINT processing_deliveries_attempt_run_fk FOREIGN KEY (attempt_id, processing_run_id) REFERENCES ingestion.processing_attempts(id, processing_run_id), CONSTRAINT processing_deliveries_attempt_envelope_unique UNIQUE (attempt_id, envelope_sha256) ); CREATE TABLE ingestion.outbox_events ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_key text NOT NULL UNIQUE CHECK (btrim(event_key) <> ''), aggregate_type text NOT NULL CHECK (aggregate_type IN ( 'processing_run', 'daily_version', 'booking_source_batch' )), aggregate_id bigint NOT NULL CHECK (aggregate_id > 0), event_type text NOT NULL CHECK (btrim(event_type) <> ''), payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'), publish_status text NOT NULL DEFAULT 'pending' CHECK ( publish_status IN ('pending', 'publishing', 'published', 'dead') ), publish_attempts integer NOT NULL DEFAULT 0 CHECK ( publish_attempts >= 0 ), available_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(), published_at timestamptz, last_error_code text, CONSTRAINT outbox_events_published_shape CHECK ( (publish_status = 'published' AND published_at IS NOT NULL) OR publish_status <> 'published' ) ); -- --------------------------------------------------------------------------- -- Booking source and row-level parse facts -- --------------------------------------------------------------------------- CREATE TABLE booking.source_batches ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source_artifact_id bigint NOT NULL UNIQUE REFERENCES ingestion.artifacts(id), source_kind text NOT NULL CHECK (source_kind IN ( 'expected_fixture', 'booking_excel' )), source_format_version text NOT NULL CHECK ( btrim(source_format_version) <> '' ), reference_year integer CHECK ( reference_year IS NULL OR reference_year BETWEEN 2000 AND 2199 ), reference_month integer CHECK ( reference_month IS NULL OR reference_month BETWEEN 1 AND 12 ), batch_status text NOT NULL CHECK (batch_status IN ( 'processing', 'accepted', 'failed' )), source_rows integer NOT NULL DEFAULT 0 CHECK (source_rows >= 0), accepted_rows integer NOT NULL DEFAULT 0 CHECK (accepted_rows >= 0), failed_rows integer NOT NULL DEFAULT 0 CHECK (failed_rows >= 0), failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), validated_at timestamptz, finished_at timestamptz, CONSTRAINT source_batches_counts_valid CHECK ( accepted_rows + failed_rows <= source_rows ), CONSTRAINT source_batches_terminal_shape CHECK ( ( batch_status = 'accepted' AND accepted_rows = source_rows AND failed_rows = 0 AND failure_code IS NULL AND validated_at IS NOT NULL AND finished_at IS NOT NULL ) OR ( batch_status = 'failed' AND failure_code IS NOT NULL AND finished_at IS NOT NULL ) OR batch_status = 'processing' ) ); CREATE TABLE booking.source_rows ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source_batch_id bigint NOT NULL REFERENCES booking.source_batches(id), source_worksheet text NOT NULL CHECK (btrim(source_worksheet) <> ''), source_row_no integer NOT NULL CHECK (source_row_no > 0), group_code_raw text NOT NULL CHECK (btrim(group_code_raw) <> ''), group_code_key text GENERATED ALWAYS AS ( upper(btrim(group_code_raw)) ) STORED, type_of_room_raw text NOT NULL CHECK (btrim(type_of_room_raw) <> ''), no_of_rooms integer NOT NULL CHECK (no_of_rooms > 0), source_row_sha256 character(64) NOT NULL CHECK ( source_row_sha256 ~ '^[0-9a-f]{64}$' ), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT source_rows_position_unique UNIQUE (source_batch_id, source_worksheet, source_row_no), CONSTRAINT source_rows_hash_unique UNIQUE (source_batch_id, source_row_sha256) ); CREATE INDEX source_rows_group_code_idx ON booking.source_rows (group_code_key); CREATE TABLE booking.parse_versions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source_row_id bigint NOT NULL REFERENCES booking.source_rows(id), version_no integer NOT NULL CHECK (version_no > 0), parse_status text NOT NULL CHECK (parse_status IN ( 'accepted', 'needs_review', 'failed' )), result_schema_version text NOT NULL CHECK ( btrim(result_schema_version) <> '' ), processor_name text NOT NULL CHECK (btrim(processor_name) <> ''), processor_version text NOT NULL CHECK (btrim(processor_version) <> ''), rule_set_sha256 character(64) NOT NULL CHECK ( rule_set_sha256 ~ '^[0-9a-f]{64}$' ), input_row_sha256 character(64) NOT NULL CHECK ( input_row_sha256 ~ '^[0-9a-f]{64}$' ), result_sha256 character(64) CHECK ( result_sha256 IS NULL OR result_sha256 ~ '^[0-9a-f]{64}$' ), result_json jsonb NOT NULL CHECK (jsonb_typeof(result_json) = 'object'), failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), validated_at timestamptz, CONSTRAINT parse_versions_source_version_unique UNIQUE (source_row_id, version_no), CONSTRAINT parse_versions_id_source_unique UNIQUE (id, source_row_id), CONSTRAINT parse_versions_status_shape CHECK ( ( parse_status = 'accepted' AND result_sha256 IS NOT NULL AND failure_code IS NULL AND validated_at IS NOT NULL ) OR parse_status = 'needs_review' OR ( parse_status = 'failed' AND failure_code IS NOT NULL ) ) ); CREATE INDEX parse_versions_source_row_idx ON booking.parse_versions (source_row_id, version_no DESC); CREATE TABLE booking.room_items ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, parse_version_id bigint NOT NULL REFERENCES booking.parse_versions(id), item_no integer NOT NULL CHECK (item_no > 0), room_type_raw text NOT NULL CHECK (btrim(room_type_raw) <> ''), room_type_code text CHECK ( room_type_code IS NULL OR btrim(room_type_code) <> '' ), quantity integer NOT NULL CHECK (quantity > 0), unit_price numeric(18,2) CHECK ( unit_price IS NULL OR unit_price >= 0 ), currency_code character(3) CHECK ( currency_code IS NULL OR currency_code ~ '^[A-Z]{3}$' ), price_token_raw text, source_fragment text, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT room_items_position_unique UNIQUE (parse_version_id, item_no) ); CREATE INDEX room_items_type_idx ON booking.room_items ( COALESCE(room_type_code, room_type_raw) ); CREATE TABLE booking.current_row_parses ( source_row_id bigint PRIMARY KEY, parse_version_id bigint NOT NULL UNIQUE, activated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT current_row_parses_version_fk FOREIGN KEY (parse_version_id, source_row_id) REFERENCES booking.parse_versions(id, source_row_id) ); CREATE OR REPLACE FUNCTION booking.validate_current_row_parse() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE source_quantity integer; parsed_quantity bigint; BEGIN SELECT source.no_of_rooms INTO source_quantity FROM booking.parse_versions AS parsed JOIN booking.source_rows AS source ON source.id = parsed.source_row_id JOIN booking.source_batches AS batch ON batch.id = source.source_batch_id WHERE parsed.id = NEW.parse_version_id AND parsed.source_row_id = NEW.source_row_id AND parsed.parse_status = 'accepted' AND batch.batch_status = 'accepted'; IF source_quantity IS NULL THEN RAISE EXCEPTION 'current row parse must reference an accepted parse in an accepted source batch'; END IF; SELECT COALESCE(sum(item.quantity), 0) INTO parsed_quantity FROM booking.room_items AS item WHERE item.parse_version_id = NEW.parse_version_id; IF parsed_quantity <> source_quantity THEN RAISE EXCEPTION 'parsed room quantity % does not equal source room quantity %', parsed_quantity, source_quantity; END IF; RETURN NEW; END; $$; CREATE TRIGGER current_row_parses_acceptance_guard BEFORE INSERT OR UPDATE ON booking.current_row_parses FOR EACH ROW EXECUTE FUNCTION booking.validate_current_row_parse(); CREATE VIEW booking.v_current_room_items AS SELECT source.id AS source_row_id, source.source_batch_id, source.source_worksheet, source.source_row_no, source.group_code_raw, source.group_code_key, source.type_of_room_raw, source.no_of_rooms AS source_no_of_rooms, parsed.id AS parse_version_id, parsed.version_no AS parse_version_no, item.item_no, item.room_type_raw, item.room_type_code, item.quantity, item.unit_price, item.currency_code, item.price_token_raw, item.source_fragment FROM booking.current_row_parses AS current_parse JOIN booking.parse_versions AS parsed ON parsed.id = current_parse.parse_version_id JOIN booking.source_rows AS source ON source.id = current_parse.source_row_id JOIN booking.source_batches AS batch ON batch.id = source.source_batch_id JOIN booking.room_items AS item ON item.parse_version_id = parsed.id WHERE batch.batch_status = 'accepted' AND parsed.parse_status = 'accepted'; CREATE VIEW booking.v_group_room_item_summary AS SELECT current_item.group_code_key, COALESCE( NULLIF(btrim(current_item.room_type_code), ''), current_item.room_type_raw ) AS booking_room_type, sum(current_item.quantity)::bigint AS quantity, count(DISTINCT current_item.source_row_id)::bigint AS source_row_count FROM booking.v_current_room_items AS current_item GROUP BY current_item.group_code_key, COALESCE( NULLIF(btrim(current_item.room_type_code), ''), current_item.room_type_raw ); CREATE VIEW booking.v_group_booking_rooms AS WITH room_summary AS ( SELECT summary.group_code_key, string_agg( summary.booking_room_type || ' × ' || summary.quantity::text, ', ' ORDER BY summary.booking_room_type ) AS booking_room, sum(summary.quantity)::bigint AS total_booking_rooms FROM booking.v_group_room_item_summary AS summary GROUP BY summary.group_code_key ), source_summary AS ( SELECT current_item.group_code_key, count(DISTINCT current_item.source_row_id)::bigint AS source_row_count FROM booking.v_current_room_items AS current_item GROUP BY current_item.group_code_key ) SELECT room_summary.group_code_key, room_summary.booking_room, room_summary.total_booking_rooms, source_summary.source_row_count FROM room_summary JOIN source_summary ON source_summary.group_code_key = room_summary.group_code_key; COMMENT ON VIEW booking.v_group_booking_rooms IS 'Query-time Group Code summary. Original source rows remain unmerged and traceable.'; -- --------------------------------------------------------------------------- -- Finance daily facts -- --------------------------------------------------------------------------- CREATE TABLE finance.daily_versions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_date date, version_no integer, processing_run_id bigint NOT NULL UNIQUE REFERENCES ingestion.processing_runs(id), source_artifact_id bigint NOT NULL REFERENCES ingestion.artifacts(id), daily_report_artifact_id bigint REFERENCES ingestion.artifacts(id), result_json_artifact_id bigint REFERENCES ingestion.artifacts(id), structured_result_artifact_id bigint REFERENCES ingestion.artifacts(id), exception_report_artifact_id bigint REFERENCES ingestion.artifacts(id), version_status text NOT NULL CHECK (version_status IN ( 'validated', 'active', 'superseded', 'rejected' )), processor_version text NOT NULL CHECK (btrim(processor_version) <> ''), rule_set_sha256 character(64) NOT NULL CHECK ( rule_set_sha256 ~ '^[0-9a-f]{64}$' ), result_schema_version text NOT NULL CHECK ( btrim(result_schema_version) <> '' ), result_sha256 character(64) NOT NULL CHECK ( result_sha256 ~ '^[0-9a-f]{64}$' ), source_rows integer NOT NULL CHECK (source_rows >= 0), retained_rows integer NOT NULL CHECK (retained_rows >= 0), excluded_rate_code_rows integer NOT NULL CHECK ( excluded_rate_code_rows >= 0 ), duplicate_rows integer NOT NULL CHECK (duplicate_rows >= 0), validation_failed_rows integer NOT NULL CHECK ( validation_failed_rows >= 0 ), price_unmatched_rows integer NOT NULL CHECK ( price_unmatched_rows >= 0 ), failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), validated_at timestamptz, activated_at timestamptz, superseded_at timestamptz, CONSTRAINT daily_versions_date_number_shape CHECK ( ( business_date IS NOT NULL AND version_no IS NOT NULL AND version_no > 0 ) OR ( business_date IS NULL AND version_no IS NULL AND version_status = 'rejected' ) ), CONSTRAINT daily_versions_business_version_unique UNIQUE (business_date, version_no), CONSTRAINT daily_versions_id_date_unique UNIQUE (id, business_date), CONSTRAINT daily_versions_counts_reconcile CHECK ( source_rows = retained_rows + excluded_rate_code_rows + duplicate_rows + validation_failed_rows + price_unmatched_rows ), CONSTRAINT daily_versions_status_shape CHECK ( ( version_status IN ('validated', 'active', 'superseded') AND business_date IS NOT NULL AND daily_report_artifact_id IS NOT NULL AND result_json_artifact_id IS NOT NULL AND structured_result_artifact_id IS NOT NULL AND exception_report_artifact_id IS NULL AND failure_code IS NULL AND validated_at IS NOT NULL ) OR ( version_status = 'rejected' AND failure_code IS NOT NULL ) ), CONSTRAINT daily_versions_active_shape CHECK ( version_status <> 'active' OR activated_at IS NOT NULL ) ); CREATE UNIQUE INDEX daily_versions_source_rule_unique ON finance.daily_versions ( source_artifact_id, business_date, processor_version, rule_set_sha256 ) WHERE business_date IS NOT NULL; CREATE INDEX daily_versions_business_date_idx ON finance.daily_versions (business_date, version_no DESC); ALTER TABLE ingestion.processing_deliveries ADD CONSTRAINT processing_deliveries_daily_version_fk FOREIGN KEY (daily_version_id) REFERENCES finance.daily_versions(id); CREATE TABLE finance.daily_records ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, daily_version_id bigint NOT NULL REFERENCES finance.daily_versions(id), source_sequence integer NOT NULL CHECK (source_sequence > 0), source_location text NOT NULL CHECK (btrim(source_location) <> ''), source_worksheet text, source_row_no integer, outcome text NOT NULL CHECK (outcome IN ( 'retained', 'excluded_rate_code', 'duplicate', 'validation_failed', 'price_unmatched' )), decision_codes text[] NOT NULL CHECK ( cardinality(decision_codes) > 0 ), duplicate_of_record_id bigint, block_code text, adults integer, children integer, company_name text, company_key text, confirmation_no text, disp_room_no text, effective_rate_amount numeric(18,2), full_name text, res_comment text, group_code_key text GENERATED ALWAYS AS ( NULLIF(upper(btrim(res_comment)), '') ) STORED, trace_text text, no_of_rooms integer, products text, rate_code text, normalized_rate_code text GENERATED ALWAYS AS ( NULLIF(upper(btrim(rate_code)), '') ) STORED, room_category_label text, arrival date, departure date, nights integer, real_price numeric(18,2), total_price numeric(18,2), kb_amount numeric(18,2), channel_key text, pricing_method text, booking_source_match_status text NOT NULL CHECK ( booking_source_match_status IN ( 'matched', 'unmatched', 'missing_group_code', 'not_checked' ) ), booking_source_match_count integer NOT NULL DEFAULT 0 CHECK ( booking_source_match_count >= 0 ), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT daily_records_source_unique UNIQUE (daily_version_id, source_sequence), CONSTRAINT daily_records_id_version_unique UNIQUE (id, daily_version_id), CONSTRAINT daily_records_duplicate_version_fk FOREIGN KEY (duplicate_of_record_id, daily_version_id) REFERENCES finance.daily_records(id, daily_version_id), CONSTRAINT daily_records_source_coordinate_shape CHECK ( (source_worksheet IS NULL AND source_row_no IS NULL) OR ( source_worksheet IS NOT NULL AND btrim(source_worksheet) <> '' AND source_row_no > 0 ) ), CONSTRAINT daily_records_duplicate_shape CHECK ( (outcome = 'duplicate' AND duplicate_of_record_id IS NOT NULL) OR (outcome <> 'duplicate' AND duplicate_of_record_id IS NULL) ), CONSTRAINT daily_records_booking_match_shape CHECK ( ( booking_source_match_status = 'matched' AND group_code_key IS NOT NULL AND booking_source_match_count > 0 ) OR ( booking_source_match_status = 'unmatched' AND group_code_key IS NOT NULL AND booking_source_match_count = 0 ) OR ( booking_source_match_status = 'missing_group_code' AND group_code_key IS NULL AND booking_source_match_count = 0 ) OR ( booking_source_match_status = 'not_checked' AND booking_source_match_count = 0 ) ), CONSTRAINT daily_records_retained_checked CHECK ( outcome <> 'retained' OR booking_source_match_status <> 'not_checked' ), 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' ) ) ), CONSTRAINT daily_records_dates_nights_valid CHECK ( outcome <> 'retained' OR ( departure >= arrival AND nights = departure - arrival ) ), CONSTRAINT daily_records_total_price_valid CHECK ( outcome <> 'retained' OR total_price = real_price * no_of_rooms * nights ), CONSTRAINT daily_records_kb_valid CHECK ( outcome <> 'retained' OR kb_amount IS NULL OR kb_amount = no_of_rooms * 100 ) ); CREATE UNIQUE INDEX daily_records_retained_dedupe_key ON finance.daily_records ( daily_version_id, disp_room_no, arrival ) WHERE outcome = 'retained'; CREATE INDEX daily_records_version_outcome_idx ON finance.daily_records (daily_version_id, outcome); CREATE INDEX daily_records_group_code_idx ON finance.daily_records (group_code_key) WHERE group_code_key IS NOT NULL; CREATE INDEX daily_records_company_date_idx ON finance.daily_records (company_key, arrival); CREATE TABLE finance.daily_channel_metrics ( daily_version_id bigint NOT NULL REFERENCES finance.daily_versions(id), channel_key text NOT NULL CHECK (btrim(channel_key) <> ''), channel_order integer NOT NULL CHECK (channel_order > 0), row_count integer NOT NULL CHECK (row_count >= 0), PRIMARY KEY (daily_version_id, channel_key), CONSTRAINT daily_channel_metrics_order_unique UNIQUE (daily_version_id, channel_order) ); CREATE TABLE finance.current_daily_versions ( business_date date PRIMARY KEY, daily_version_id bigint NOT NULL, activated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT current_daily_versions_version_fk FOREIGN KEY (daily_version_id, business_date) REFERENCES finance.daily_versions(id, business_date) ); CREATE OR REPLACE FUNCTION finance.validate_current_daily_version() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM finance.daily_versions AS version WHERE version.id = NEW.daily_version_id AND version.business_date = NEW.business_date AND version.version_status = 'active' ) THEN RAISE EXCEPTION 'current business date must reference an active daily version'; END IF; RETURN NEW; END; $$; CREATE TRIGGER current_daily_versions_active_guard BEFORE INSERT OR UPDATE ON finance.current_daily_versions FOR EACH ROW EXECUTE FUNCTION finance.validate_current_daily_version(); -- --------------------------------------------------------------------------- -- Read models. Monthly and channel rows are generated, not duplicated. -- --------------------------------------------------------------------------- CREATE VIEW finance.v_active_daily_facts AS SELECT version.business_date, record.* FROM finance.current_daily_versions AS current_version JOIN finance.daily_versions AS version ON version.id = current_version.daily_version_id JOIN finance.daily_records AS record ON record.daily_version_id = current_version.daily_version_id WHERE record.outcome = 'retained'; CREATE VIEW finance.v_monthly_report_rows AS SELECT fact.business_date, fact.id AS daily_record_id, fact.arrival, fact.departure, fact.nights, fact.adults, fact.children, fact.block_code, fact.no_of_rooms, fact.company_name, fact.confirmation_no, fact.disp_room_no, fact.effective_rate_amount AS rate_amount, fact.full_name, fact.res_comment, fact.trace_text, fact.products, fact.rate_code, fact.room_category_label AS room_category, fact.real_price, booking_room.booking_room, fact.total_price FROM finance.v_active_daily_facts AS fact LEFT JOIN booking.v_group_booking_rooms AS booking_room ON booking_room.group_code_key = fact.group_code_key; CREATE VIEW finance.v_channel_details AS SELECT fact.business_date, fact.id AS daily_record_id, fact.channel_key, fact.arrival, fact.departure, fact.nights, fact.block_code, fact.res_comment, fact.group_code_key, booking_room.booking_room, fact.total_price AS total_booking_price FROM finance.v_active_daily_facts AS fact LEFT JOIN booking.v_group_booking_rooms AS booking_room ON booking_room.group_code_key = fact.group_code_key; CREATE VIEW finance.v_company_report_source AS SELECT fact.business_date, fact.id AS daily_record_id, fact.channel_key, fact.company_key, fact.company_name, fact.block_code, fact.group_code_key, fact.res_comment, booking_room.booking_room AS type_of_room, booking_room.total_booking_rooms, fact.no_of_rooms AS actual_room_quantity, fact.arrival, fact.departure, fact.nights, fact.real_price, fact.total_price FROM finance.v_active_daily_facts AS fact LEFT JOIN booking.v_group_booking_rooms AS booking_room ON booking_room.group_code_key = fact.group_code_key; CREATE VIEW finance.v_daily_processing_audit AS SELECT version.business_date, version.id AS daily_version_id, version.version_no, version.version_status, run.id AS processing_run_id, run.run_key, artifact.original_filename AS source_filename, artifact.sha256 AS source_sha256, record.id AS daily_record_id, record.source_sequence, record.source_location, record.outcome, record.decision_codes, record.duplicate_of_record_id, record.block_code, record.res_comment, record.group_code_key, record.rate_code, record.booking_source_match_status, record.booking_source_match_count, version.processor_version, version.rule_set_sha256, version.result_schema_version, version.validated_at, version.activated_at FROM finance.daily_versions AS version JOIN ingestion.processing_runs AS run ON run.id = version.processing_run_id JOIN ingestion.artifacts AS artifact ON artifact.id = version.source_artifact_id JOIN finance.daily_records AS record ON record.daily_version_id = version.id; COMMENT ON VIEW finance.v_active_daily_facts IS 'Current active version and retained rows only; authoritative source for ordinary downstream programs.'; COMMENT ON VIEW finance.v_monthly_report_rows IS 'Monthly export projection. RATE_AMOUNT/ROOM_CATEGORY aliases and Booking Room enrichment are computed without storing monthly rows.'; COMMENT ON VIEW finance.v_channel_details IS 'Channel detail projection. total_booking_price is exactly Finance daily total_price.'; COMMENT ON VIEW finance.v_daily_processing_audit IS 'All outcomes and deterministic lineage for later read-only processing trace APIs.'; COMMIT;