feat: 方案设计、数据库和接口设计基本完成
This commit is contained in:
@@ -0,0 +1,591 @@
|
||||
-- Atlas Seller Kit: collection box, product editor, publishing and online listing schema
|
||||
-- PostgreSQL 15+. Run once after 001_foundation.sql through the migration tool.
|
||||
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
CREATE TYPE collection_source_kind AS ENUM ('platform', 'manual');
|
||||
CREATE TYPE collection_item_status AS ENUM (
|
||||
'collecting', 'received', 'materializing', 'ready_for_edit',
|
||||
'needs_review', 'failed', 'archived', 'deleted'
|
||||
);
|
||||
CREATE TYPE attribute_scope AS ENUM ('common', 'variant');
|
||||
CREATE TYPE data_confirmation_status AS ENUM ('confirmed', 'ambiguous', 'missing');
|
||||
CREATE TYPE platform_variant_listing_status AS ENUM ('not_listed', 'submitting', 'imported', 'failed');
|
||||
CREATE TYPE media_candidate_role AS ENUM ('source_main', 'source_sku', 'source_detail', 'ai_generated', 'manual_upload');
|
||||
CREATE TYPE media_derivative_kind AS ENUM ('watermark');
|
||||
CREATE TYPE processing_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
|
||||
CREATE TYPE seller_enrichment_observation_status AS ENUM ('observed', 'unavailable', 'failed');
|
||||
CREATE TYPE ai_task_kind AS ENUM ('copy', 'category_suggestion', 'image_suite', 'dimension_image');
|
||||
CREATE TYPE publish_job_status AS ENUM ('queued', 'running', 'succeeded', 'partially_succeeded', 'failed', 'cancelled');
|
||||
CREATE TYPE publish_job_item_status AS ENUM ('queued', 'submitting', 'polling', 'imported', 'failed', 'skipped');
|
||||
CREATE TYPE platform_listing_source AS ENUM ('atlas_published', 'external_sync');
|
||||
CREATE TYPE platform_sync_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
|
||||
|
||||
-- Default can later be changed by subscription/operations policy without changing collection-item rows.
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN collection_item_limit INTEGER NOT NULL DEFAULT 1000 CHECK (collection_item_limit >= 0);
|
||||
|
||||
-- A collection item is workspace-wide and intentionally has no target platform or store.
|
||||
CREATE TABLE collection_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
source_kind collection_source_kind NOT NULL,
|
||||
source_platform_id UUID REFERENCES platforms(id) ON DELETE RESTRICT,
|
||||
source_item_key TEXT NOT NULL,
|
||||
source_url TEXT,
|
||||
source_title TEXT,
|
||||
source_currency_code CHAR(3) REFERENCES currencies(code),
|
||||
source_min_price NUMERIC(18,4) CHECK (source_min_price IS NULL OR source_min_price >= 0),
|
||||
source_max_price NUMERIC(18,4) CHECK (source_max_price IS NULL OR source_max_price >= 0),
|
||||
note TEXT,
|
||||
status collection_item_status NOT NULL DEFAULT 'received',
|
||||
raw_evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
parser_version TEXT,
|
||||
collected_at TIMESTAMPTZ,
|
||||
last_error JSONB,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK (
|
||||
(source_kind = 'platform' AND source_platform_id IS NOT NULL)
|
||||
OR (source_kind = 'manual' AND source_platform_id IS NULL)
|
||||
),
|
||||
CHECK (source_max_price IS NULL OR source_min_price IS NULL OR source_max_price >= source_min_price),
|
||||
CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_collection_items_active_source_identity
|
||||
ON collection_items (workspace_id, source_platform_id, source_item_key)
|
||||
WHERE source_platform_id IS NOT NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX ix_collection_items_workspace_list
|
||||
ON collection_items (workspace_id, updated_at DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX ix_collection_items_workspace_source_status
|
||||
ON collection_items (workspace_id, source_platform_id, status, updated_at DESC) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Locking the one workspace row serializes only collection-box creates/restores in that workspace.
|
||||
CREATE OR REPLACE FUNCTION enforce_collection_item_limit() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
configured_limit INTEGER;
|
||||
active_count INTEGER;
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT' AND NEW.deleted_at IS NULL)
|
||||
OR (TG_OP = 'UPDATE' AND OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL) THEN
|
||||
SELECT collection_item_limit INTO configured_limit
|
||||
FROM atlas.workspaces
|
||||
WHERE id = NEW.workspace_id
|
||||
FOR UPDATE;
|
||||
|
||||
SELECT count(*) INTO active_count
|
||||
FROM atlas.collection_items
|
||||
WHERE workspace_id = NEW.workspace_id AND deleted_at IS NULL;
|
||||
|
||||
IF active_count >= configured_limit THEN
|
||||
RAISE EXCEPTION 'collection item limit (%) reached for workspace %', configured_limit, NEW.workspace_id
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER enforce_collection_item_limit_before_write
|
||||
BEFORE INSERT OR UPDATE OF deleted_at ON collection_items
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_collection_item_limit();
|
||||
|
||||
|
||||
-- products and product_variants are Atlas collection-box drafts, not online products.
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
collection_item_id UUID NOT NULL,
|
||||
source_title TEXT,
|
||||
brand TEXT,
|
||||
model TEXT,
|
||||
barcode TEXT,
|
||||
purchase_url TEXT,
|
||||
supply_note TEXT,
|
||||
default_physical_length_mm NUMERIC(12,3) CHECK (default_physical_length_mm IS NULL OR default_physical_length_mm > 0),
|
||||
default_physical_width_mm NUMERIC(12,3) CHECK (default_physical_width_mm IS NULL OR default_physical_width_mm > 0),
|
||||
default_physical_height_mm NUMERIC(12,3) CHECK (default_physical_height_mm IS NULL OR default_physical_height_mm > 0),
|
||||
default_net_weight_g NUMERIC(12,3) CHECK (default_net_weight_g IS NULL OR default_net_weight_g > 0),
|
||||
default_package_length_mm NUMERIC(12,3) CHECK (default_package_length_mm IS NULL OR default_package_length_mm > 0),
|
||||
default_package_width_mm NUMERIC(12,3) CHECK (default_package_width_mm IS NULL OR default_package_width_mm > 0),
|
||||
default_package_height_mm NUMERIC(12,3) CHECK (default_package_height_mm IS NULL OR default_package_height_mm > 0),
|
||||
default_shipping_weight_g NUMERIC(12,3) CHECK (default_shipping_weight_g IS NULL OR default_shipping_weight_g > 0),
|
||||
physical_spec_status data_confirmation_status NOT NULL DEFAULT 'missing',
|
||||
package_spec_status data_confirmation_status NOT NULL DEFAULT 'missing',
|
||||
version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, collection_item_id)
|
||||
REFERENCES collection_items(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, collection_item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE product_variants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
source_variant_key TEXT,
|
||||
display_name TEXT,
|
||||
source_sku TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
purchase_cost_cny NUMERIC(18,4) CHECK (purchase_cost_cny IS NULL OR purchase_cost_cny >= 0),
|
||||
specification_values JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
physical_length_mm NUMERIC(12,3) CHECK (physical_length_mm IS NULL OR physical_length_mm > 0),
|
||||
physical_width_mm NUMERIC(12,3) CHECK (physical_width_mm IS NULL OR physical_width_mm > 0),
|
||||
physical_height_mm NUMERIC(12,3) CHECK (physical_height_mm IS NULL OR physical_height_mm > 0),
|
||||
net_weight_g NUMERIC(12,3) CHECK (net_weight_g IS NULL OR net_weight_g > 0),
|
||||
package_length_mm NUMERIC(12,3) CHECK (package_length_mm IS NULL OR package_length_mm > 0),
|
||||
package_width_mm NUMERIC(12,3) CHECK (package_width_mm IS NULL OR package_width_mm > 0),
|
||||
package_height_mm NUMERIC(12,3) CHECK (package_height_mm IS NULL OR package_height_mm > 0),
|
||||
shipping_weight_g NUMERIC(12,3) CHECK (shipping_weight_g IS NULL OR shipping_weight_g > 0),
|
||||
physical_spec_status data_confirmation_status,
|
||||
package_spec_status data_confirmation_status,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_product_variants_source_key
|
||||
ON product_variants (workspace_id, product_id, source_variant_key)
|
||||
WHERE source_variant_key IS NOT NULL;
|
||||
CREATE INDEX ix_product_variants_product ON product_variants (workspace_id, product_id, sort_order) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Source image URLs are untrusted remote inputs. Transfer state exists before a media_asset can be created.
|
||||
CREATE TABLE collection_asset_transfers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
collection_item_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
source_variant_id UUID,
|
||||
role media_candidate_role NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
referer_url TEXT,
|
||||
source_confidence NUMERIC(5,4) CHECK (source_confidence IS NULL OR source_confidence BETWEEN 0 AND 1),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
status processing_status NOT NULL DEFAULT 'queued',
|
||||
asset_id UUID,
|
||||
error_payload JSONB,
|
||||
task_key UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
CHECK ((status = 'succeeded') = (asset_id IS NOT NULL)),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at),
|
||||
UNIQUE (workspace_id, task_key)
|
||||
);
|
||||
CREATE INDEX ix_collection_asset_transfers_workspace_status
|
||||
ON collection_asset_transfers (workspace_id, status, created_at DESC);
|
||||
|
||||
-- Sanitized results from Ozon Seller web local-observation calls. No Cookie or authorization data is stored.
|
||||
CREATE TABLE collection_seller_enrichment_observations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
collection_item_id UUID NOT NULL,
|
||||
source_variant_id UUID,
|
||||
interface_code TEXT NOT NULL,
|
||||
status seller_enrichment_observation_status NOT NULL,
|
||||
sanitized_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
unavailable_reason TEXT,
|
||||
parser_version TEXT,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||
CHECK ((status = 'observed') = (unavailable_reason IS NULL)),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_seller_enrichment_observations_collection
|
||||
ON collection_seller_enrichment_observations (workspace_id, collection_item_id, observed_at DESC);
|
||||
|
||||
-- One collection draft can have a separate profile for every target platform.
|
||||
CREATE TABLE product_platform_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
target_store_id UUID NOT NULL,
|
||||
market_code TEXT NOT NULL DEFAULT 'RU',
|
||||
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
brand TEXT,
|
||||
model TEXT,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
platform_product_category_id UUID,
|
||||
platform_type_id TEXT,
|
||||
category_source TEXT CHECK (category_source IN ('source_capture', 'ai_suggestion', 'manual')),
|
||||
version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, target_store_id, platform_id)
|
||||
REFERENCES stores(workspace_id, 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),
|
||||
UNIQUE (workspace_id, id, platform_id),
|
||||
UNIQUE (workspace_id, product_id, platform_id)
|
||||
);
|
||||
CREATE INDEX ix_product_platform_profiles_store ON product_platform_profiles (workspace_id, target_store_id, updated_at DESC);
|
||||
|
||||
-- Stores every SKU's first-import state in the current platform profile. Imported rows are never reused for re-import.
|
||||
CREATE TABLE product_variant_platform_listings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
product_variant_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
offer_id TEXT,
|
||||
currency_code CHAR(3) REFERENCES currencies(code),
|
||||
selling_price NUMERIC(18,4) CHECK (selling_price IS NULL OR selling_price >= 0),
|
||||
list_price NUMERIC(18,4) CHECK (list_price IS NULL OR list_price >= 0),
|
||||
pricing_quote_id UUID,
|
||||
status platform_variant_listing_status NOT NULL DEFAULT 'not_listed',
|
||||
platform_product_id TEXT,
|
||||
first_imported_at TIMESTAMPTZ,
|
||||
last_publish_job_item_id UUID,
|
||||
last_error JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, profile_id, platform_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, product_variant_id)
|
||||
REFERENCES product_variants(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id)
|
||||
REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, profile_id, product_variant_id),
|
||||
CHECK (list_price IS NULL OR selling_price IS NULL OR list_price >= selling_price),
|
||||
CHECK (
|
||||
(status = 'imported' AND first_imported_at IS NOT NULL AND platform_product_id IS NOT NULL)
|
||||
OR status <> 'imported'
|
||||
)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_variant_platform_listings_store_offer
|
||||
ON product_variant_platform_listings (workspace_id, store_id, platform_id, offer_id)
|
||||
WHERE offer_id IS NOT NULL;
|
||||
CREATE INDEX ix_variant_platform_listings_profile_status
|
||||
ON product_variant_platform_listings (workspace_id, profile_id, status);
|
||||
|
||||
CREATE TABLE product_attributes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
product_variant_id UUID,
|
||||
scope attribute_scope NOT NULL,
|
||||
platform_attribute_id TEXT NOT NULL,
|
||||
complex_id TEXT NOT NULL DEFAULT '0',
|
||||
values JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
language_code TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
confirmation_status data_confirmation_status NOT NULL DEFAULT 'confirmed',
|
||||
raw_value TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE CASCADE,
|
||||
CHECK ((scope = 'common' AND product_variant_id IS NULL) OR (scope = 'variant' AND product_variant_id IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_product_attributes_common
|
||||
ON product_attributes (workspace_id, profile_id, platform_attribute_id, complex_id, COALESCE(language_code, ''))
|
||||
WHERE scope = 'common';
|
||||
CREATE UNIQUE INDEX ux_product_attributes_variant
|
||||
ON product_attributes (workspace_id, profile_id, product_variant_id, platform_attribute_id, complex_id, COALESCE(language_code, ''))
|
||||
WHERE scope = 'variant';
|
||||
|
||||
CREATE TABLE product_media_candidates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
asset_id UUID NOT NULL,
|
||||
source_variant_id UUID,
|
||||
role media_candidate_role NOT NULL,
|
||||
source_url TEXT,
|
||||
source_confidence NUMERIC(5,4) CHECK (source_confidence IS NULL OR source_confidence BETWEEN 0 AND 1),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||
UNIQUE (workspace_id, product_id, asset_id, role)
|
||||
);
|
||||
|
||||
-- Selected media is platform-SKU-specific because each platform can have different image limits and watermarked variants.
|
||||
CREATE TABLE product_variant_listing_media (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
variant_listing_id UUID NOT NULL,
|
||||
asset_id UUID NOT NULL,
|
||||
source_asset_id UUID,
|
||||
sort_order SMALLINT NOT NULL CHECK (sort_order >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, variant_listing_id)
|
||||
REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, variant_listing_id, sort_order),
|
||||
UNIQUE (workspace_id, variant_listing_id, asset_id)
|
||||
);
|
||||
|
||||
CREATE TABLE media_derivatives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
kind media_derivative_kind NOT NULL,
|
||||
source_asset_id UUID NOT NULL,
|
||||
output_asset_id UUID,
|
||||
watermark_template_version_id UUID,
|
||||
status processing_status NOT NULL DEFAULT 'queued',
|
||||
task_key UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
error_payload JSONB,
|
||||
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, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, watermark_template_version_id)
|
||||
REFERENCES watermark_template_versions(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, task_key),
|
||||
CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
|
||||
CREATE TABLE product_ai_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
profile_id UUID,
|
||||
product_variant_id UUID,
|
||||
kind ai_task_kind NOT NULL,
|
||||
status processing_status NOT NULL DEFAULT 'queued',
|
||||
provider_code TEXT,
|
||||
model_code TEXT,
|
||||
input_snapshot JSONB NOT NULL,
|
||||
output_payload JSONB,
|
||||
error_payload JSONB,
|
||||
queue_job_id TEXT,
|
||||
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,
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
CREATE INDEX ix_product_ai_tasks_workspace_status ON product_ai_tasks (workspace_id, status, created_at DESC);
|
||||
|
||||
CREATE TABLE ai_category_suggestions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
ai_task_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
platform_product_category_id UUID NOT NULL,
|
||||
platform_type_id TEXT,
|
||||
confidence NUMERIC(5,4) CHECK (confidence BETWEEN 0 AND 1),
|
||||
rationale TEXT,
|
||||
rank SMALLINT NOT NULL CHECK (rank > 0),
|
||||
applied_at TIMESTAMPTZ,
|
||||
applied_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, ai_task_id) REFERENCES product_ai_tasks(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, profile_id, platform_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (platform_id, platform_product_category_id)
|
||||
REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, ai_task_id, rank)
|
||||
);
|
||||
|
||||
-- Durable publish intent and per-SKU Ozon import outcomes. BullMQ job ids are auxiliary, never the only state.
|
||||
CREATE TABLE product_publish_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
status publish_job_status NOT NULL DEFAULT 'queued',
|
||||
idempotency_key UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
queue_job_id TEXT,
|
||||
request_snapshot JSONB NOT NULL,
|
||||
error_payload JSONB,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
completed_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, profile_id, platform_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id)
|
||||
REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, idempotency_key),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
|
||||
CREATE TABLE product_publish_job_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
publish_job_id UUID NOT NULL,
|
||||
variant_listing_id UUID NOT NULL,
|
||||
status publish_job_item_status NOT NULL DEFAULT 'queued',
|
||||
platform_task_id TEXT,
|
||||
platform_product_id TEXT,
|
||||
request_snapshot JSONB NOT NULL,
|
||||
response_snapshot JSONB,
|
||||
error_payload JSONB,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, publish_job_id) REFERENCES product_publish_jobs(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, variant_listing_id)
|
||||
REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, publish_job_id, variant_listing_id),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
ALTER TABLE product_variant_platform_listings
|
||||
ADD CONSTRAINT fk_variant_listing_last_publish_job_item
|
||||
FOREIGN KEY (last_publish_job_item_id) REFERENCES product_publish_job_items(id) ON DELETE SET NULL;
|
||||
|
||||
-- Online products are a separate, store-owned Ozon mirror. They never become cross-platform collection-box drafts.
|
||||
CREATE TABLE platform_product_sync_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
status platform_sync_status NOT NULL DEFAULT 'queued',
|
||||
trigger_source TEXT NOT NULL CHECK (trigger_source IN ('manual', 'scheduled', 'recovery')),
|
||||
queue_job_id TEXT,
|
||||
cursor_started TEXT,
|
||||
cursor_finished TEXT,
|
||||
listed_count INTEGER NOT NULL DEFAULT 0 CHECK (listed_count >= 0),
|
||||
upserted_count INTEGER NOT NULL DEFAULT 0 CHECK (upserted_count >= 0),
|
||||
failed_count INTEGER NOT NULL DEFAULT 0 CHECK (failed_count >= 0),
|
||||
error_payload JSONB,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at)
|
||||
);
|
||||
CREATE INDEX ix_platform_product_sync_runs_store ON platform_product_sync_runs (workspace_id, store_id, created_at DESC);
|
||||
CREATE UNIQUE INDEX ux_platform_product_sync_runs_one_active_per_store
|
||||
ON platform_product_sync_runs (workspace_id, store_id)
|
||||
WHERE status IN ('queued', 'running');
|
||||
|
||||
CREATE TABLE platform_listings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
remote_product_id TEXT NOT NULL,
|
||||
offer_id TEXT NOT NULL,
|
||||
remote_sku TEXT,
|
||||
name TEXT,
|
||||
currency_code CHAR(3) REFERENCES currencies(code),
|
||||
selling_price NUMERIC(18,4),
|
||||
list_price NUMERIC(18,4),
|
||||
visible BOOLEAN,
|
||||
remote_state TEXT,
|
||||
moderation_status TEXT,
|
||||
validation_status TEXT,
|
||||
stock_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
image_summary JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
error_payload JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
visibility_details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source platform_listing_source NOT NULL,
|
||||
atlas_variant_listing_id UUID,
|
||||
last_sync_run_id UUID,
|
||||
last_synced_at TIMESTAMPTZ NOT NULL,
|
||||
missing_since TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ,
|
||||
raw_payload JSONB NOT 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, atlas_variant_listing_id)
|
||||
REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (workspace_id, last_sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE SET NULL,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, store_id, platform_id, remote_product_id),
|
||||
UNIQUE (workspace_id, store_id, platform_id, offer_id)
|
||||
);
|
||||
CREATE INDEX ix_platform_listings_store_visibility ON platform_listings (workspace_id, store_id, visible, updated_at DESC);
|
||||
CREATE INDEX ix_platform_listings_atlas_mapping ON platform_listings (workspace_id, atlas_variant_listing_id) WHERE atlas_variant_listing_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE platform_listing_sync_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
platform_listing_id UUID NOT NULL,
|
||||
sync_run_id UUID NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, platform_listing_id) REFERENCES platform_listings(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, platform_listing_id, sync_run_id)
|
||||
);
|
||||
|
||||
-- The foundation migration intentionally created product_variant_id as a logical FK.
|
||||
ALTER TABLE pricing_quotes
|
||||
ADD COLUMN product_platform_profile_id UUID,
|
||||
ADD CONSTRAINT fk_pricing_quotes_product_variant
|
||||
FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||
ADD CONSTRAINT fk_pricing_quotes_product_profile
|
||||
FOREIGN KEY (workspace_id, product_platform_profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE SET NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_row_updated_at() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'collection_items', 'products', 'product_variants', 'collection_asset_transfers', 'product_platform_profiles',
|
||||
'product_variant_platform_listings', 'product_attributes', 'product_variant_listing_media',
|
||||
'product_publish_jobs', 'product_publish_job_items', 'platform_listings'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('CREATE TRIGGER set_%I_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION set_row_updated_at()', table_name, table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Tenant isolation. app_current_workspace_id() is defined in 001_foundation.sql.
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'collection_items', 'products', 'product_variants', 'collection_asset_transfers', 'collection_seller_enrichment_observations', 'product_platform_profiles',
|
||||
'product_variant_platform_listings', 'product_attributes', 'product_media_candidates',
|
||||
'product_variant_listing_media', 'media_derivatives', 'product_ai_tasks', 'ai_category_suggestions',
|
||||
'product_publish_jobs', 'product_publish_job_items', 'platform_product_sync_runs',
|
||||
'platform_listings', 'platform_listing_sync_snapshots'
|
||||
]
|
||||
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 $$;
|
||||
Reference in New Issue
Block a user