Files
wyndham-ARR/database/001_booking_finance_mvp_schema.sql
2026-07-29 16:38:05 +08:00

608 lines
22 KiB
PL/PgSQL

-- 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;