Files

679 lines
32 KiB
PL/PgSQL

-- Atlas Seller Kit: cloud foundation schema
-- PostgreSQL 15+ recommended. Run through a migration tool, not from the browser.
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS atlas;
SET search_path TO atlas, public;
CREATE TYPE membership_role AS ENUM ('owner', 'admin', 'editor', 'viewer');
CREATE TYPE account_status AS ENUM ('active', 'disabled', 'pending_review');
CREATE TYPE platform_status AS ENUM ('active', 'disabled');
CREATE TYPE store_status AS ENUM ('verifying', 'active', 'credential_invalid', 'sync_error', 'disabled', 'deleted');
CREATE TYPE credential_status AS ENUM ('unknown', 'valid', 'invalid', 'expired', 'rotation_required');
CREATE TYPE secret_kind AS ENUM ('seller_api_key', 'browser_cookie');
CREATE TYPE watermark_kind AS ENUM ('text', 'image');
CREATE TYPE watermark_position AS ENUM ('top_left', 'top_center', 'top_right', 'middle_left', 'center', 'middle_right', 'bottom_left', 'bottom_center', 'bottom_right');
CREATE TYPE record_status AS ENUM ('draft', 'active', 'archived', 'superseded');
CREATE TYPE rule_source AS ENUM ('manual_rule', 'competitor_capture', 'platform_sync', 'product_observed');
CREATE TYPE fulfillment_mode AS ENUM ('rfbs', 'fbs', 'fbo', 'fbp', 'unknown');
CREATE TYPE billing_weight_basis AS ENUM ('actual_only', 'max_actual_volume', 'conditional_volume');
CREATE TYPE quote_status AS ENUM ('draft', 'calculated', 'confirmed', 'invalid', 'superseded');
CREATE TYPE exchange_rate_source AS ENUM ('cbr', 'exchange_rate_api_open', 'frankfurter', 'manual');
CREATE TYPE exchange_rate_kind AS ENUM ('official_reference', 'market_reference', 'manual');
CREATE TYPE fx_rate_origin AS ENUM ('system_rate', 'policy_fixed', 'quote_override');
CREATE TYPE operation_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
CREATE TYPE media_upload_status AS ENUM ('pending', 'uploaded', 'validating', 'ready', 'failed', 'expired');
CREATE TYPE idempotency_request_status AS ENUM ('in_progress', 'completed');
CREATE TYPE outbox_event_status AS ENUM ('pending', 'dispatching', 'dispatched', 'dead_letter');
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT NOT NULL UNIQUE CHECK (length(email) <= 254 AND position('@' IN email) > 1),
password_hash TEXT NOT NULL,
display_name TEXT,
status account_status NOT NULL DEFAULT 'active',
email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug CITEXT NOT NULL UNIQUE,
status record_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE workspace_memberships (
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
role membership_role NOT NULL DEFAULT 'editor',
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, user_id)
);
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
ip_hash TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (expires_at > created_at)
);
CREATE TABLE platforms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code CITEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status platform_status NOT NULL DEFAULT 'active',
capabilities JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE currencies (
code CHAR(3) PRIMARY KEY,
name TEXT NOT NULL,
symbol TEXT NOT NULL,
decimal_places SMALLINT NOT NULL DEFAULT 2 CHECK (decimal_places BETWEEN 0 AND 6),
is_enabled BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE platform_currency_support (
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
is_enabled BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (platform_id, currency_code)
);
-- Global immutable rate history. rate always means one unit of base_currency_code equals rate units of quote_currency_code.
CREATE TABLE exchange_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
base_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
quote_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
rate NUMERIC(24,10) NOT NULL CHECK (rate > 0),
kind exchange_rate_kind NOT NULL,
source exchange_rate_source NOT NULL,
source_reference TEXT,
effective_on DATE NOT NULL,
observed_at TIMESTAMPTZ,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (base_currency_code <> quote_currency_code),
UNIQUE (base_currency_code, quote_currency_code, kind, source, effective_on)
);
CREATE INDEX ix_exchange_rates_current_lookup
ON exchange_rates (base_currency_code, quote_currency_code, kind, effective_on DESC, fetched_at DESC);
CREATE TABLE stores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
platform_client_id TEXT NOT NULL,
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
is_default BOOLEAN NOT NULL DEFAULT false,
note TEXT,
status store_status NOT NULL DEFAULT 'verifying',
credential_status credential_status NOT NULL DEFAULT 'unknown',
credential_checked_at TIMESTAMPTZ,
last_error_code TEXT,
last_error_message TEXT,
last_error_at TIMESTAMPTZ,
api_key_expires_at TIMESTAMPTZ,
product_limit INTEGER CHECK (product_limit IS NULL OR product_limit >= 0),
product_limit_checked_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, id, platform_id),
UNIQUE (workspace_id, platform_id, platform_client_id)
);
CREATE UNIQUE INDEX ux_stores_one_default_per_platform
ON stores (workspace_id, platform_id)
WHERE is_default AND deleted_at IS NULL;
CREATE INDEX ix_stores_workspace_platform ON stores (workspace_id, platform_id, created_at DESC);
CREATE TABLE store_secrets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
store_id UUID NOT NULL,
kind secret_kind NOT NULL,
secret_ciphertext BYTEA NOT NULL,
secret_nonce BYTEA NOT NULL,
encryption_key_version TEXT NOT NULL,
fingerprint TEXT NOT NULL,
status credential_status NOT NULL DEFAULT 'unknown',
expires_at TIMESTAMPTZ,
last_verified_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE,
UNIQUE (workspace_id, store_id, kind, fingerprint)
);
CREATE UNIQUE INDEX ux_store_secrets_one_current_kind
ON store_secrets (workspace_id, store_id, kind)
WHERE revoked_at IS NULL;
CREATE TABLE store_warehouses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
store_id UUID NOT NULL,
platform_warehouse_id TEXT NOT NULL,
name TEXT NOT NULL,
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
is_rfbs BOOLEAN,
status TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE,
UNIQUE (workspace_id, store_id, platform_warehouse_id)
);
-- Durable API-visible operation state. Queue job ids are auxiliary and never the only record.
CREATE TABLE operations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
kind TEXT NOT NULL,
status operation_status NOT NULL DEFAULT 'queued',
resource_type TEXT,
resource_id UUID,
result JSONB NOT NULL DEFAULT '{}'::jsonb,
error_code TEXT,
error_message TEXT,
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
CHECK (completed_at IS NULL OR completed_at >= created_at),
UNIQUE (workspace_id, id)
);
CREATE INDEX ix_operations_workspace_status ON operations (workspace_id, status, created_at DESC);
-- Durable replay record for authenticated, retryable business writes.
-- request_fingerprint is an HMAC of canonical request content, never a raw request body.
CREATE TABLE idempotency_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
http_method TEXT NOT NULL CHECK (http_method IN ('POST', 'PUT', 'PATCH', 'DELETE')),
request_path TEXT NOT NULL CHECK (request_path ~ '^/api/'),
idempotency_key UUID NOT NULL,
request_fingerprint TEXT NOT NULL,
status idempotency_request_status NOT NULL DEFAULT 'in_progress',
response_status SMALLINT,
response_body JSONB,
resource_type TEXT,
resource_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '48 hours',
CHECK (expires_at > created_at),
CHECK (
(status = 'in_progress' AND completed_at IS NULL AND response_status IS NULL)
OR (status = 'completed' AND completed_at IS NOT NULL AND response_status BETWEEN 200 AND 599)
),
UNIQUE (workspace_id, user_id, http_method, request_path, idempotency_key),
UNIQUE (workspace_id, id)
);
CREATE INDEX ix_idempotency_requests_expiry
ON idempotency_requests (expires_at);
-- Transactional Outbox. A dispatcher makes these events available to BullMQ only after their business transaction commits.
-- event_payload contains identifiers and immutable snapshots only; it must not contain credentials or access URLs.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
aggregate_type TEXT NOT NULL,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
event_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
deduplication_key TEXT,
status outbox_event_status NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_by TEXT,
locked_until TIMESTAMPTZ,
dispatched_at TIMESTAMPTZ,
last_error_code TEXT,
last_error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
(status = 'dispatching' AND locked_by IS NOT NULL AND locked_until IS NOT NULL)
OR (status <> 'dispatching' AND locked_by IS NULL AND locked_until IS NULL)
),
CHECK (dispatched_at IS NULL OR status = 'dispatched'),
UNIQUE (workspace_id, id)
);
CREATE INDEX ix_outbox_events_dispatch
ON outbox_events (workspace_id, status, next_attempt_at, created_at)
WHERE status IN ('pending', 'dispatching');
CREATE INDEX ix_outbox_events_aggregate
ON outbox_events (workspace_id, aggregate_type, aggregate_id, created_at DESC);
CREATE TABLE media_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
storage_provider TEXT NOT NULL, -- local_minio / qiniu_kodo; provider configuration is server-side only.
bucket TEXT NOT NULL,
object_key TEXT NOT NULL,
content_type TEXT NOT NULL,
byte_size BIGINT NOT NULL CHECK (byte_size > 0),
checksum_sha256 TEXT NOT NULL,
width_px INTEGER CHECK (width_px IS NULL OR width_px > 0),
height_px INTEGER CHECK (height_px IS NULL OR height_px > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, storage_provider, bucket, object_key),
UNIQUE (workspace_id, checksum_sha256)
);
-- A short-lived, durable record for browser direct uploads. object_key is never returned as public file identity.
CREATE TABLE media_upload_intents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
purpose TEXT NOT NULL,
storage_provider TEXT NOT NULL,
bucket TEXT NOT NULL,
object_key TEXT NOT NULL,
expected_content_type TEXT NOT NULL,
expected_byte_size BIGINT NOT NULL CHECK (expected_byte_size > 0),
expected_checksum_sha256 TEXT NOT NULL,
status media_upload_status NOT NULL DEFAULT 'pending',
asset_id UUID,
error_code TEXT,
error_message TEXT,
expires_at TIMESTAMPTZ NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
CHECK (expires_at > created_at),
CHECK (completed_at IS NULL OR completed_at >= created_at),
CHECK ((status = 'ready') = (asset_id IS NOT NULL)),
UNIQUE (workspace_id, id),
UNIQUE (storage_provider, bucket, object_key)
);
CREATE INDEX ix_media_upload_intents_expiry ON media_upload_intents (status, expires_at);
CREATE TABLE watermark_fonts (
code CITEXT PRIMARY KEY,
display_name TEXT NOT NULL,
status record_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE watermark_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
kind watermark_kind NOT NULL,
current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
status record_status NOT NULL DEFAULT 'active',
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, name)
);
CREATE TABLE watermark_template_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
template_id UUID NOT NULL,
version INTEGER NOT NULL CHECK (version > 0),
kind watermark_kind NOT NULL,
text_content TEXT,
image_asset_id UUID,
font_code CITEXT REFERENCES watermark_fonts(code),
font_size_px SMALLINT CHECK (font_size_px BETWEEN 12 AND 60),
opacity NUMERIC(5,4) NOT NULL CHECK (opacity BETWEEN 0 AND 1),
scale_ratio NUMERIC(7,4) CHECK (scale_ratio > 0),
position watermark_position NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, image_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, template_id, version),
CHECK (
(kind = 'text' AND text_content IS NOT NULL AND font_code IS NOT NULL AND font_size_px IS NOT NULL AND image_asset_id IS NULL)
OR (kind = 'image' AND image_asset_id IS NOT NULL AND scale_ratio IS NOT NULL AND text_content IS NULL)
)
);
CREATE TABLE store_watermark_bindings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
store_id UUID NOT NULL,
template_id UUID NOT NULL,
bound_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
bound_at TIMESTAMPTZ NOT NULL DEFAULT now(),
unbound_at TIMESTAMPTZ,
FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE RESTRICT,
CHECK (unbound_at IS NULL OR unbound_at >= bound_at)
);
CREATE UNIQUE INDEX ux_store_watermark_one_active
ON store_watermark_bindings (workspace_id, store_id)
WHERE unbound_at IS NULL;
CREATE TABLE platform_product_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
parent_id UUID REFERENCES platform_product_categories(id) ON DELETE RESTRICT,
external_category_id TEXT NOT NULL,
external_type_id TEXT,
name TEXT NOT NULL,
path TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (platform_id, id),
UNIQUE (platform_id, external_category_id, external_type_id)
);
CREATE TABLE commission_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
parent_id UUID REFERENCES commission_categories(id) ON DELETE RESTRICT,
source_category_key TEXT,
name TEXT NOT NULL,
path TEXT NOT NULL,
source rule_source NOT NULL,
status record_status NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (platform_id, id),
UNIQUE (platform_id, path)
);
CREATE TABLE commission_category_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
commission_category_id UUID NOT NULL REFERENCES commission_categories(id) ON DELETE CASCADE,
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
market_code TEXT NOT NULL DEFAULT 'RU',
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
min_price NUMERIC(18,4),
max_price NUMERIC(18,4),
min_inclusive BOOLEAN NOT NULL DEFAULT false,
max_inclusive BOOLEAN NOT NULL DEFAULT true,
commission_rate NUMERIC(7,6) NOT NULL CHECK (commission_rate >= 0 AND commission_rate < 1),
source rule_source NOT NULL,
version TEXT NOT NULL,
status record_status NOT NULL DEFAULT 'draft',
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (min_price IS NULL OR min_price >= 0),
CHECK (max_price IS NULL OR max_price >= 0),
CHECK (min_price IS NULL OR max_price IS NULL OR min_price <= max_price),
CHECK (effective_to IS NULL OR effective_to > effective_from)
);
CREATE TABLE platform_product_category_commission_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
platform_product_category_id UUID NOT NULL,
commission_category_id UUID NOT NULL,
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
market_code TEXT NOT NULL DEFAULT 'RU',
priority SMALLINT NOT NULL DEFAULT 100 CHECK (priority >= 0),
source rule_source NOT NULL DEFAULT 'manual_rule',
status record_status NOT NULL DEFAULT 'draft',
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (platform_id, platform_product_category_id)
REFERENCES platform_product_categories(platform_id, id) ON DELETE CASCADE,
FOREIGN KEY (platform_id, commission_category_id)
REFERENCES commission_categories(platform_id, id) ON DELETE RESTRICT,
CHECK (effective_to IS NULL OR effective_to > effective_from)
);
CREATE INDEX ix_category_commission_mapping_lookup
ON platform_product_category_commission_mappings (platform_product_category_id, fulfillment, market_code, status, effective_from DESC, priority);
CREATE TABLE logistics_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code CITEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status record_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE logistics_rate_cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES logistics_providers(id) ON DELETE RESTRICT,
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE RESTRICT,
fulfillment fulfillment_mode NOT NULL,
market_code TEXT NOT NULL DEFAULT 'RU',
version TEXT NOT NULL,
source_name TEXT NOT NULL,
source_uri TEXT,
status record_status NOT NULL DEFAULT 'draft',
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ,
reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (platform_id, id),
UNIQUE (provider_id, platform_id, fulfillment, market_code, version),
CHECK (effective_to IS NULL OR effective_to > effective_from)
);
CREATE TABLE logistics_product_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rate_card_id UUID NOT NULL REFERENCES logistics_rate_cards(id) ON DELETE CASCADE,
code CITEXT NOT NULL,
name TEXT NOT NULL,
merchandise_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
min_merchandise_value NUMERIC(18,4),
max_merchandise_value NUMERIC(18,4),
min_value_inclusive BOOLEAN NOT NULL DEFAULT false,
max_value_inclusive BOOLEAN NOT NULL DEFAULT true,
min_actual_weight_kg NUMERIC(12,3) NOT NULL DEFAULT 0.001 CHECK (min_actual_weight_kg >= 0.001),
max_actual_weight_kg NUMERIC(12,3),
max_sum_dimensions_cm NUMERIC(12,3),
max_longest_side_cm NUMERIC(12,3),
max_second_longest_side_cm NUMERIC(12,3),
billing_basis billing_weight_basis NOT NULL,
volume_divisor NUMERIC(12,3),
volume_condition_sum_gt_cm NUMERIC(12,3),
volume_round_scale SMALLINT CHECK (volume_round_scale BETWEEN 0 AND 6),
billing_round_scale SMALLINT CHECK (billing_round_scale BETWEEN 0 AND 6),
max_volume_weight_kg NUMERIC(12,3),
volume_limit_round_scale SMALLINT CHECK (volume_limit_round_scale BETWEEN 0 AND 6),
return_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (rate_card_id, code),
CHECK (max_merchandise_value IS NULL OR max_merchandise_value >= 0),
CHECK (min_merchandise_value IS NULL OR min_merchandise_value >= 0),
CHECK (min_merchandise_value IS NULL OR max_merchandise_value IS NULL OR min_merchandise_value <= max_merchandise_value),
CHECK (max_actual_weight_kg IS NULL OR max_actual_weight_kg >= min_actual_weight_kg),
CHECK ((billing_basis = 'actual_only' AND volume_divisor IS NULL) OR (billing_basis <> 'actual_only' AND volume_divisor IS NOT NULL))
);
CREATE TABLE logistics_transport_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rate_card_id UUID NOT NULL REFERENCES logistics_rate_cards(id) ON DELETE CASCADE,
code CITEXT NOT NULL,
name TEXT NOT NULL,
speed_label TEXT,
min_transit_days SMALLINT CHECK (min_transit_days >= 0),
max_transit_days SMALLINT CHECK (max_transit_days >= min_transit_days),
status record_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (rate_card_id, code)
);
CREATE TABLE logistics_rate_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_type_id UUID NOT NULL REFERENCES logistics_product_types(id) ON DELETE CASCADE,
transport_method_id UUID NOT NULL REFERENCES logistics_transport_methods(id) ON DELETE CASCADE,
fixed_fee_cny NUMERIC(18,4) NOT NULL DEFAULT 0 CHECK (fixed_fee_cny >= 0),
per_billable_kg_fee_cny NUMERIC(18,4) NOT NULL CHECK (per_billable_kg_fee_cny >= 0),
min_charge_cny NUMERIC(18,4) CHECK (min_charge_cny IS NULL OR min_charge_cny >= 0),
surcharge_rules JSONB NOT NULL DEFAULT '[]'::jsonb,
status record_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (product_type_id, transport_method_id)
);
CREATE TABLE pricing_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
store_id UUID NOT NULL,
platform_id UUID NOT NULL,
name TEXT NOT NULL,
note TEXT,
status record_status NOT NULL DEFAULT 'active',
is_default BOOLEAN NOT NULL DEFAULT false,
rate_card_id UUID NOT NULL,
default_transport_method_id UUID REFERENCES logistics_transport_methods(id) ON DELETE RESTRICT,
domestic_shipping_and_label_fee_cny NUMERIC(18,4) NOT NULL DEFAULT 0 CHECK (domestic_shipping_and_label_fee_cny >= 0),
ad_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (ad_rate >= 0 AND ad_rate < 1),
last_mile_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (last_mile_rate >= 0 AND last_mile_rate < 1),
withdrawal_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (withdrawal_rate >= 0 AND withdrawal_rate < 1),
other_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (other_rate >= 0 AND other_rate < 1),
desired_markup_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (desired_markup_rate >= 0),
list_price_discount_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (list_price_discount_rate >= 0 AND list_price_discount_rate < 1),
allow_commission_override BOOLEAN NOT NULL DEFAULT false,
fx_strategy TEXT NOT NULL DEFAULT 'system' CHECK (fx_strategy IN ('system', 'fixed', 'override_allowed')),
fixed_cny_to_rub_rate NUMERIC(18,8),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
FOREIGN KEY (platform_id, rate_card_id) REFERENCES logistics_rate_cards(platform_id, id) ON DELETE RESTRICT,
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, id, store_id, platform_id),
UNIQUE (workspace_id, store_id, name),
CHECK ((fx_strategy = 'fixed' AND fixed_cny_to_rub_rate > 0) OR (fx_strategy <> 'fixed' AND fixed_cny_to_rub_rate IS NULL))
);
CREATE UNIQUE INDEX ux_pricing_policies_one_default
ON pricing_policies (workspace_id, store_id)
WHERE is_default AND status = 'active';
CREATE TABLE pricing_policy_transport_methods (
workspace_id UUID NOT NULL,
pricing_policy_id UUID NOT NULL,
transport_method_id UUID NOT NULL REFERENCES logistics_transport_methods(id) ON DELETE RESTRICT,
is_enabled BOOLEAN NOT NULL DEFAULT true,
sort_order SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (workspace_id, pricing_policy_id, transport_method_id),
FOREIGN KEY (workspace_id, pricing_policy_id) REFERENCES pricing_policies(workspace_id, id) ON DELETE CASCADE
);
CREATE TABLE pricing_quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
store_id UUID NOT NULL,
platform_id UUID NOT NULL,
pricing_policy_id UUID,
product_variant_id UUID, -- Logical FK; add real FK in the product/SKU migration.
platform_product_category_id UUID,
fulfillment fulfillment_mode NOT NULL,
purchase_cost_cny NUMERIC(18,4) NOT NULL CHECK (purchase_cost_cny >= 0),
package_weight_kg NUMERIC(12,3) NOT NULL CHECK (package_weight_kg >= 0.001),
package_length_cm NUMERIC(12,3) NOT NULL CHECK (package_length_cm > 0),
package_width_cm NUMERIC(12,3) NOT NULL CHECK (package_width_cm > 0),
package_height_cm NUMERIC(12,3) NOT NULL CHECK (package_height_cm > 0),
fx_cny_to_rub NUMERIC(18,8) NOT NULL CHECK (fx_cny_to_rub > 0),
fx_rate_origin fx_rate_origin NOT NULL DEFAULT 'system_rate',
exchange_rate_id UUID REFERENCES exchange_rates(id) ON DELETE SET NULL,
input_snapshot JSONB NOT NULL,
status quote_status NOT NULL DEFAULT 'draft',
selected_option_id UUID,
calculated_at TIMESTAMPTZ,
confirmed_at TIMESTAMPTZ,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, pricing_policy_id, store_id, platform_id)
REFERENCES pricing_policies(workspace_id, id, store_id, platform_id) ON DELETE RESTRICT,
FOREIGN KEY (platform_id, platform_product_category_id)
REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT,
UNIQUE (workspace_id, id)
);
CREATE TABLE pricing_quote_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
quote_id UUID NOT NULL,
logistics_rate_rule_id UUID REFERENCES logistics_rate_rules(id) ON DELETE SET NULL,
commission_rule_id UUID REFERENCES commission_category_rules(id) ON DELETE SET NULL,
logistics_product_type_id UUID REFERENCES logistics_product_types(id) ON DELETE SET NULL,
transport_method_id UUID REFERENCES logistics_transport_methods(id) ON DELETE SET NULL,
merchandise_value_cny NUMERIC(18,4) NOT NULL CHECK (merchandise_value_cny >= 0),
selling_price_rub NUMERIC(18,4) NOT NULL CHECK (selling_price_rub >= 0),
list_price_rub NUMERIC(18,4) NOT NULL CHECK (list_price_rub >= selling_price_rub),
billable_weight_kg NUMERIC(12,3) NOT NULL CHECK (billable_weight_kg >= 0.001),
logistics_fee_cny NUMERIC(18,4) NOT NULL CHECK (logistics_fee_cny >= 0),
commission_fee_rub NUMERIC(18,4) NOT NULL CHECK (commission_fee_rub >= 0),
domestic_fee_cny NUMERIC(18,4) NOT NULL CHECK (domestic_fee_cny >= 0),
ad_fee_rub NUMERIC(18,4) NOT NULL CHECK (ad_fee_rub >= 0),
last_mile_fee_rub NUMERIC(18,4) NOT NULL CHECK (last_mile_fee_rub >= 0),
withdrawal_fee_rub NUMERIC(18,4) NOT NULL CHECK (withdrawal_fee_rub >= 0),
other_fee_rub NUMERIC(18,4) NOT NULL CHECK (other_fee_rub >= 0),
profit_rub NUMERIC(18,4) NOT NULL,
calculation_snapshot JSONB NOT NULL,
is_valid BOOLEAN NOT NULL DEFAULT true,
validation_errors JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, quote_id) REFERENCES pricing_quotes(workspace_id, id) ON DELETE CASCADE,
UNIQUE (workspace_id, quote_id, transport_method_id, logistics_product_type_id, commission_rule_id)
);
ALTER TABLE pricing_quotes
ADD CONSTRAINT fk_pricing_quotes_selected_option
FOREIGN KEY (selected_option_id) REFERENCES pricing_quote_options(id) ON DELETE SET NULL;
-- Tenant isolation. The API must set app.workspace_id with SET LOCAL after authorization.
CREATE OR REPLACE FUNCTION app_current_workspace_id() RETURNS UUID
LANGUAGE SQL STABLE AS $$
SELECT NULLIF(current_setting('app.workspace_id', true), '')::UUID
$$;
DO $$
DECLARE table_name TEXT;
BEGIN
FOREACH table_name IN ARRAY ARRAY[
'stores', 'store_secrets', 'store_warehouses', 'operations', 'idempotency_requests', 'outbox_events',
'media_assets', 'media_upload_intents',
'watermark_templates', 'watermark_template_versions', 'store_watermark_bindings',
'pricing_policies', 'pricing_policy_transport_methods', 'pricing_quotes', 'pricing_quote_options'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
EXECUTE format(
'CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())',
table_name
);
END LOOP;
END $$;