-- Shared booking facts + Finance processing persistence schema v1.1 -- PostgreSQL 15+ -- Target database: booking_test (connect to that database before running). -- REVIEW DRAFT ONLY: this file has not been executed against any database. BEGIN; CREATE SCHEMA booking; CREATE SCHEMA finance; COMMENT ON SCHEMA booking IS 'Shared simulated booking attachment inputs, Agent parse versions, and current booking facts.'; COMMENT ON SCHEMA finance IS 'Finance-owned Opera daily facts and report-version lineage derived from shared booking facts.'; CREATE TABLE booking.file_objects ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, file_kind text NOT NULL CHECK (file_kind IN ( 'booking_excel', 'opera_xml', 'daily_xlsx', 'monthly_xlsx', 'company_ten_day_xlsx', 'exception_xlsx', 'result_json' )), original_filename text NOT NULL CHECK (btrim(original_filename) <> ''), storage_key text NOT NULL UNIQUE, 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 file_objects_storage_key_is_relative CHECK ( storage_key !~ '^/' AND storage_key !~ '(^|/)\.\.(/|$)' ), CONSTRAINT file_objects_kind_hash_unique UNIQUE (file_kind, sha256) ); CREATE TABLE booking.booking_imports ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source_file_id bigint NOT NULL UNIQUE REFERENCES booking.file_objects(id), source_sheet_name text NOT NULL CHECK (btrim(source_sheet_name) <> ''), import_status text NOT NULL CHECK (import_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(), finished_at timestamptz, CONSTRAINT booking_import_counts_valid CHECK ( accepted_rows + failed_rows <= source_rows ), CONSTRAINT booking_import_failure_shape CHECK ( (import_status = 'failed' AND failure_code IS NOT NULL) OR (import_status <> 'failed') ) ); CREATE TABLE booking.booking_source_rows ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, import_id bigint NOT NULL REFERENCES booking.booking_imports(id), source_row_no integer NOT NULL CHECK (source_row_no > 0), tour_code_raw text NOT NULL CHECK (btrim(tour_code_raw) <> ''), group_code_key text GENERATED ALWAYS AS (upper(btrim(tour_code_raw))) STORED, hotel_raw text NOT NULL CHECK (btrim(hotel_raw) <> ''), row_sha256 character(64) NOT NULL CHECK (row_sha256 ~ '^[0-9a-f]{64}$'), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT booking_source_rows_position_unique UNIQUE (import_id, source_row_no), CONSTRAINT booking_source_rows_group_unique UNIQUE (import_id, group_code_key), CONSTRAINT booking_source_rows_hash_unique UNIQUE (import_id, row_sha256), CONSTRAINT booking_source_rows_id_group_unique UNIQUE (id, group_code_key) ); CREATE INDEX booking_source_rows_group_idx ON booking.booking_source_rows (group_code_key); CREATE TABLE booking.booking_parse_versions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source_row_id bigint NOT NULL, group_code_key text NOT NULL CHECK ( group_code_key = upper(btrim(group_code_key)) AND group_code_key <> '' ), version_no integer NOT NULL CHECK (version_no > 0), parse_status text NOT NULL CHECK (parse_status IN ( 'accepted', 'needs_review', 'failed' )), booking_state text NOT NULL DEFAULT 'active' CHECK (booking_state IN ( 'active', 'cancelled' )), agent_name text NOT NULL CHECK (btrim(agent_name) <> ''), agent_version text NOT NULL CHECK (btrim(agent_version) <> ''), result_sha256 character(64) CHECK ( result_sha256 IS NULL OR result_sha256 ~ '^[0-9a-f]{64}$' ), result_json jsonb NOT NULL DEFAULT '{}'::jsonb, failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), validated_at timestamptz, CONSTRAINT booking_parse_version_unique UNIQUE (group_code_key, version_no), CONSTRAINT booking_parse_id_group_unique UNIQUE (id, group_code_key), CONSTRAINT booking_parse_source_group_fk FOREIGN KEY (source_row_id, group_code_key) REFERENCES booking.booking_source_rows(id, group_code_key), CONSTRAINT booking_parse_status_shape CHECK ( (parse_status = 'accepted' AND result_sha256 IS NOT NULL AND validated_at IS NOT NULL AND failure_code IS NULL) OR (parse_status = 'needs_review') OR (parse_status = 'failed' AND failure_code IS NOT NULL) ) ); CREATE INDEX booking_parse_versions_source_row_idx ON booking.booking_parse_versions (source_row_id); CREATE TABLE booking.booking_stays ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, parse_version_id bigint NOT NULL REFERENCES booking.booking_parse_versions(id), segment_no integer NOT NULL CHECK (segment_no > 0), arrival_date date NOT NULL, departure_date date NOT NULL, nights integer GENERATED ALWAYS AS (departure_date - arrival_date) STORED, source_fragment text, CONSTRAINT booking_stays_dates_valid CHECK (departure_date > arrival_date), CONSTRAINT booking_stays_segment_unique UNIQUE (parse_version_id, segment_no) ); CREATE INDEX booking_stays_dates_idx ON booking.booking_stays (arrival_date, departure_date); CREATE TABLE booking.booking_room_items ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, stay_id bigint NOT NULL REFERENCES booking.booking_stays(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 NOT NULL CHECK (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), price_token_raw text, source_fragment text, CONSTRAINT booking_room_items_position_unique UNIQUE (stay_id, item_no) ); CREATE INDEX booking_room_items_type_idx ON booking.booking_room_items (room_type_code); CREATE TABLE booking.current_group_bookings ( group_code_key text PRIMARY KEY CHECK ( group_code_key = upper(btrim(group_code_key)) AND group_code_key <> '' ), parse_version_id bigint NOT NULL, activated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT current_group_booking_parse_fk FOREIGN KEY (parse_version_id, group_code_key) REFERENCES booking.booking_parse_versions(id, group_code_key) ); CREATE OR REPLACE FUNCTION booking.validate_current_group_booking() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM booking.booking_parse_versions AS parsed WHERE parsed.id = NEW.parse_version_id AND parsed.group_code_key = NEW.group_code_key AND parsed.parse_status = 'accepted' ) THEN RAISE EXCEPTION 'current Group Code must reference an accepted parse version'; END IF; RETURN NEW; END; $$; CREATE TRIGGER current_group_booking_acceptance_guard BEFORE INSERT OR UPDATE ON booking.current_group_bookings FOR EACH ROW EXECUTE FUNCTION booking.validate_current_group_booking(); CREATE TABLE finance.daily_versions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_date date, version_no integer, source_file_id bigint NOT NULL REFERENCES booking.file_objects(id), version_status text NOT NULL CHECK (version_status IN ( 'processing', 'failed', 'validated', 'active', 'superseded' )), processor_version text NOT NULL CHECK (btrim(processor_version) <> ''), rule_set_sha256 character(64) CHECK ( rule_set_sha256 IS NULL OR rule_set_sha256 ~ '^[0-9a-f]{64}$' ), result_schema_version text NOT NULL DEFAULT '1.0', source_rows integer NOT NULL DEFAULT 0 CHECK (source_rows >= 0), removed_by_rate_code integer NOT NULL DEFAULT 0 CHECK (removed_by_rate_code >= 0), removed_as_duplicates integer NOT NULL DEFAULT 0 CHECK (removed_as_duplicates >= 0), output_rows integer NOT NULL DEFAULT 0 CHECK (output_rows >= 0), daily_report_file_id bigint REFERENCES booking.file_objects(id), result_json_file_id bigint REFERENCES booking.file_objects(id), exception_report_file_id bigint REFERENCES booking.file_objects(id), failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), validated_at timestamptz, activated_at timestamptz, superseded_at timestamptz, CONSTRAINT daily_version_date_number_shape CHECK ( (business_date IS NULL AND version_no IS NULL) OR (business_date IS NOT NULL AND version_no > 0) ), CONSTRAINT daily_version_business_version_unique UNIQUE (business_date, version_no), CONSTRAINT daily_version_id_date_unique UNIQUE (id, business_date), CONSTRAINT daily_version_success_counts CHECK ( version_status NOT IN ('validated', 'active', 'superseded') OR source_rows = removed_by_rate_code + removed_as_duplicates + output_rows ), CONSTRAINT daily_version_active_shape CHECK ( version_status <> 'active' OR ( business_date IS NOT NULL AND validated_at IS NOT NULL AND activated_at IS NOT NULL AND daily_report_file_id IS NOT NULL AND result_json_file_id IS NOT NULL AND failure_code IS NULL ) ), CONSTRAINT daily_version_failed_shape CHECK ( version_status <> 'failed' OR failure_code IS NOT NULL ) ); CREATE INDEX daily_versions_business_date_idx ON finance.daily_versions (business_date, version_no DESC); 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, outcome text NOT NULL CHECK (outcome IN ( 'retained', 'excluded_rate_code', 'duplicate', 'validation_failed', 'price_unmatched' )), decision_codes text[] NOT NULL DEFAULT ARRAY[]::text[], duplicate_of_record_id bigint, block_code text, group_code_key text CHECK ( group_code_key IS NULL OR ( group_code_key = upper(btrim(group_code_key)) AND group_code_key <> '' ) ), 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, trace_text text, no_of_rooms integer, products text, rate_code text, normalized_rate_code text, 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_parse_version_id bigint, booking_match_status text NOT NULL DEFAULT 'not_required' CHECK (booking_match_status IN ( 'matched', 'unmatched', 'parse_failed', 'missing_group_code', 'not_required' )), 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_booking_group_fk FOREIGN KEY (booking_parse_version_id, group_code_key) REFERENCES booking.booking_parse_versions(id, group_code_key), CONSTRAINT daily_records_people_valid CHECK ( (adults IS NULL OR adults >= 0) AND (children IS NULL OR children >= 0) ), CONSTRAINT daily_records_room_count_valid CHECK ( no_of_rooms IS NULL OR no_of_rooms > 0 ), CONSTRAINT daily_records_retained_shape CHECK ( outcome <> 'retained' OR ( rate_code IS NOT NULL AND btrim(rate_code) <> '' AND normalized_rate_code IS NOT NULL AND btrim(normalized_rate_code) <> '' 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 btrim(full_name) <> '' AND adults IS NOT NULL AND children IS NOT NULL AND no_of_rooms IS NOT NULL AND arrival IS NOT NULL AND departure IS NOT NULL AND nights IS NOT NULL 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 IS NOT NULL AND btrim(pricing_method) <> '' ) ), CONSTRAINT daily_records_dates_and_nights_valid CHECK ( arrival IS NULL OR departure IS NULL OR nights IS NULL OR ( departure > arrival AND nights = departure - arrival ) ), CONSTRAINT daily_records_total_valid CHECK ( real_price IS NULL OR no_of_rooms IS NULL OR nights IS NULL OR total_price IS NULL OR total_price = real_price * no_of_rooms * nights ), CONSTRAINT daily_records_kb_valid CHECK ( kb_amount IS NULL OR no_of_rooms IS NULL OR kb_amount = no_of_rooms * 100 ), 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_match_status = 'matched' AND group_code_key IS NOT NULL AND booking_parse_version_id IS NOT NULL) OR (booking_match_status = 'unmatched' AND group_code_key IS NOT NULL AND booking_parse_version_id IS NULL) OR (booking_match_status = 'parse_failed' AND group_code_key IS NOT NULL) OR (booking_match_status = 'missing_group_code' AND group_code_key IS NULL AND booking_parse_version_id IS NULL) OR (booking_match_status = 'not_required' AND booking_parse_version_id IS NULL) ) ); 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_group_code_idx ON finance.daily_records (group_code_key); CREATE INDEX daily_records_company_date_idx ON finance.daily_records (company_key, arrival); CREATE INDEX daily_records_version_outcome_idx ON finance.daily_records (daily_version_id, outcome); CREATE TABLE finance.daily_channel_metrics ( daily_version_id bigint NOT NULL REFERENCES finance.daily_versions(id), worksheet text NOT NULL CHECK (btrim(worksheet) <> ''), row_count integer NOT NULL CHECK (row_count >= 0), PRIMARY KEY (daily_version_id, worksheet) ); 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_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_version_active_guard BEFORE INSERT OR UPDATE ON finance.current_daily_versions FOR EACH ROW EXECUTE FUNCTION finance.validate_current_daily_version(); CREATE TABLE finance.report_versions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, report_kind text NOT NULL CHECK (report_kind IN ( 'monthly_channel', 'company_ten_day' )), period_start date NOT NULL, period_end date NOT NULL, company_scope text NOT NULL DEFAULT '*', version_no integer NOT NULL CHECK (version_no > 0), report_status text NOT NULL CHECK (report_status IN ( 'generating', 'failed', 'validated', 'active', 'superseded' )), artifact_file_id bigint REFERENCES booking.file_objects(id), result_json_file_id bigint REFERENCES booking.file_objects(id), is_current boolean NOT NULL DEFAULT false, failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), validated_at timestamptz, activated_at timestamptz, superseded_at timestamptz, CONSTRAINT report_period_valid CHECK (period_end >= period_start), CONSTRAINT report_version_scope_unique UNIQUE ( report_kind, period_start, period_end, company_scope, version_no ), CONSTRAINT report_current_shape CHECK ( NOT is_current OR ( report_status = 'active' AND artifact_file_id IS NOT NULL AND validated_at IS NOT NULL AND activated_at IS NOT NULL AND failure_code IS NULL ) ), CONSTRAINT report_failed_shape CHECK ( report_status <> 'failed' OR failure_code IS NOT NULL ) ); CREATE UNIQUE INDEX report_versions_one_current_scope ON finance.report_versions ( report_kind, period_start, period_end, company_scope ) WHERE is_current; CREATE TABLE finance.report_daily_versions ( report_version_id bigint NOT NULL REFERENCES finance.report_versions(id), business_date date NOT NULL, daily_version_id bigint NOT NULL, PRIMARY KEY (report_version_id, business_date), CONSTRAINT report_daily_version_fk FOREIGN KEY (daily_version_id, business_date) REFERENCES finance.daily_versions(id, business_date) ); CREATE TABLE finance.report_booking_versions ( report_version_id bigint NOT NULL REFERENCES finance.report_versions(id), group_code_key text NOT NULL, booking_parse_version_id bigint NOT NULL, PRIMARY KEY (report_version_id, group_code_key), CONSTRAINT report_booking_version_fk FOREIGN KEY (booking_parse_version_id, group_code_key) REFERENCES booking.booking_parse_versions(id, group_code_key) ); CREATE VIEW booking.v_current_group_room_items AS SELECT current_booking.group_code_key, source_row.tour_code_raw, parsed.id AS parse_version_id, parsed.version_no AS parse_version_no, parsed.booking_state, source_row.import_id, source_row.source_row_no, stay.segment_no, stay.arrival_date, stay.departure_date, stay.nights, room.item_no, room.room_type_raw, room.room_type_code, room.quantity, room.unit_price, room.price_token_raw FROM booking.current_group_bookings AS current_booking JOIN booking.booking_parse_versions AS parsed ON parsed.id = current_booking.parse_version_id JOIN booking.booking_source_rows AS source_row ON source_row.id = parsed.source_row_id JOIN booking.booking_stays AS stay ON stay.parse_version_id = parsed.id JOIN booking.booking_room_items AS room ON room.stay_id = stay.id; CREATE VIEW finance.v_active_daily_facts AS SELECT records.* FROM finance.current_daily_versions AS current_version JOIN finance.daily_records AS records ON records.daily_version_id = current_version.daily_version_id WHERE records.outcome = 'retained'; CREATE VIEW finance.v_company_report_source AS SELECT current_version.business_date, records.id AS daily_record_id, records.channel_key, records.company_key, records.company_name, records.block_code, records.group_code_key, records.res_comment, records.room_category_label AS type_of_room, records.no_of_rooms AS actual_room_quantity, records.arrival, records.departure, records.nights, records.real_price, records.total_price, records.booking_parse_version_id, records.booking_match_status FROM finance.current_daily_versions AS current_version JOIN finance.daily_records AS records ON records.daily_version_id = current_version.daily_version_id WHERE records.outcome = 'retained'; COMMENT ON VIEW booking.v_current_group_room_items IS 'Exact Group Code lookup source for Agent-formatted original room information.'; COMMENT ON VIEW finance.v_active_daily_facts IS 'Only retained rows from each business date current active daily version.'; COMMENT ON VIEW finance.v_company_report_source IS 'Privacy-minimized daily facts used to build company ten-day room reports.'; COMMIT;