592 lines
29 KiB
PL/PgSQL
592 lines
29 KiB
PL/PgSQL
-- 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 $$;
|