feat(product-edit):商品编辑页初版

This commit is contained in:
R524809
2026-09-09 15:11:58 +08:00
parent 319887726e
commit 130a005231
19 changed files with 1541 additions and 21 deletions
@@ -0,0 +1,186 @@
SET search_path TO atlas, public;
-- M3.0 turns the collection-time draft into an editable product aggregate.
-- Keep this migration deliberately narrower than publishing, pricing, AI, and
-- online-sync models: those capabilities need their own immutable snapshots.
ALTER TABLE products RENAME COLUMN version TO revision;
ALTER TABLE products
ADD COLUMN brand TEXT,
ADD COLUMN model TEXT,
ADD COLUMN barcode TEXT,
ADD COLUMN purchase_url TEXT,
ADD COLUMN supply_note TEXT,
ADD COLUMN default_physical_length_mm NUMERIC(12,3) CHECK (default_physical_length_mm IS NULL OR default_physical_length_mm > 0),
ADD COLUMN default_physical_width_mm NUMERIC(12,3) CHECK (default_physical_width_mm IS NULL OR default_physical_width_mm > 0),
ADD COLUMN default_physical_height_mm NUMERIC(12,3) CHECK (default_physical_height_mm IS NULL OR default_physical_height_mm > 0),
ADD COLUMN default_net_weight_g NUMERIC(12,3) CHECK (default_net_weight_g IS NULL OR default_net_weight_g > 0),
ADD COLUMN default_package_length_mm NUMERIC(12,3) CHECK (default_package_length_mm IS NULL OR default_package_length_mm > 0),
ADD COLUMN default_package_width_mm NUMERIC(12,3) CHECK (default_package_width_mm IS NULL OR default_package_width_mm > 0),
ADD COLUMN default_package_height_mm NUMERIC(12,3) CHECK (default_package_height_mm IS NULL OR default_package_height_mm > 0),
ADD COLUMN default_shipping_weight_g NUMERIC(12,3) CHECK (default_shipping_weight_g IS NULL OR default_shipping_weight_g > 0),
ADD COLUMN physical_spec_status TEXT NOT NULL DEFAULT 'missing' CHECK (physical_spec_status IN ('missing', 'ambiguous', 'confirmed')),
ADD COLUMN package_spec_status TEXT NOT NULL DEFAULT 'missing' CHECK (package_spec_status IN ('missing', 'ambiguous', 'confirmed'));
ALTER TABLE product_variants
ADD COLUMN purchase_cost_cny NUMERIC(18,4) CHECK (purchase_cost_cny IS NULL OR purchase_cost_cny >= 0),
ADD COLUMN specification_values JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN physical_length_mm NUMERIC(12,3) CHECK (physical_length_mm IS NULL OR physical_length_mm > 0),
ADD COLUMN physical_width_mm NUMERIC(12,3) CHECK (physical_width_mm IS NULL OR physical_width_mm > 0),
ADD COLUMN physical_height_mm NUMERIC(12,3) CHECK (physical_height_mm IS NULL OR physical_height_mm > 0),
ADD COLUMN net_weight_g NUMERIC(12,3) CHECK (net_weight_g IS NULL OR net_weight_g > 0),
ADD COLUMN package_length_mm NUMERIC(12,3) CHECK (package_length_mm IS NULL OR package_length_mm > 0),
ADD COLUMN package_width_mm NUMERIC(12,3) CHECK (package_width_mm IS NULL OR package_width_mm > 0),
ADD COLUMN package_height_mm NUMERIC(12,3) CHECK (package_height_mm IS NULL OR package_height_mm > 0),
ADD COLUMN shipping_weight_g NUMERIC(12,3) CHECK (shipping_weight_g IS NULL OR shipping_weight_g > 0),
ADD COLUMN physical_spec_status TEXT CHECK (physical_spec_status IS NULL OR physical_spec_status IN ('missing', 'ambiguous', 'confirmed')),
ADD COLUMN package_spec_status TEXT CHECK (package_spec_status IS NULL OR package_spec_status IN ('missing', 'ambiguous', 'confirmed'));
CREATE UNIQUE INDEX ux_product_variants_product_identity
ON product_variants (workspace_id, id, product_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 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,
fulfillment TEXT NOT NULL DEFAULT 'unknown' CHECK (fulfillment IN ('rfbs', 'fbs', 'fbo', 'fbp', 'unknown')),
title TEXT,
description 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')),
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 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, product_id),
UNIQUE (workspace_id, product_id, platform_id),
UNIQUE (workspace_id, id, product_id, platform_id)
);
CREATE INDEX ix_product_platform_profiles_store
ON product_platform_profiles (workspace_id, target_store_id, updated_at DESC);
-- product_id is intentionally repeated on this relation. The two composite
-- foreign keys make it impossible to join a profile for product A to a SKU of
-- product B, even when both records are otherwise valid in one workspace.
CREATE TABLE product_variant_platform_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
product_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),
status TEXT NOT NULL DEFAULT 'not_listed' CHECK (status IN ('not_listed', 'submitting', 'imported', 'failed')),
platform_product_id TEXT,
first_imported_at TIMESTAMPTZ,
last_error JSONB,
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, profile_id, product_id, platform_id)
REFERENCES product_platform_profiles(workspace_id, id, product_id, platform_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, product_variant_id, product_id)
REFERENCES product_variants(workspace_id, id, product_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') OR (first_imported_at IS NOT NULL AND platform_product_id IS NOT NULL))
);
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,
product_id UUID NOT NULL,
profile_id UUID NOT NULL,
product_variant_id UUID,
scope TEXT NOT NULL CHECK (scope IN ('common', 'variant')),
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 TEXT NOT NULL DEFAULT 'confirmed' CHECK (confirmation_status IN ('missing', 'ambiguous', 'confirmed')),
raw_value TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, profile_id, product_id)
REFERENCES product_platform_profiles(workspace_id, id, product_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, product_variant_id, product_id)
REFERENCES product_variants(workspace_id, id, product_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';
-- A category change may remove an attribute from the active platform schema.
-- Keep the user's value as a first-class draft rather than returning a warning
-- that disappears on the next editor read.
CREATE TABLE product_unmapped_attributes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
product_id UUID NOT NULL,
profile_id UUID NOT NULL,
platform_id UUID NOT NULL,
product_variant_id UUID,
original_platform_category_id UUID,
original_platform_attribute_id TEXT NOT NULL,
original_complex_id TEXT NOT NULL DEFAULT '0',
original_name TEXT NOT NULL,
values JSONB NOT NULL DEFAULT '[]'::jsonb,
language_code TEXT,
reason TEXT NOT NULL CHECK (reason IN ('category_changed', 'attribute_unavailable')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'restored', 'discarded')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
FOREIGN KEY (workspace_id, profile_id, product_id, platform_id)
REFERENCES product_platform_profiles(workspace_id, id, product_id, platform_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, product_variant_id, product_id)
REFERENCES product_variants(workspace_id, id, product_id) ON DELETE CASCADE,
FOREIGN KEY (platform_id, original_platform_category_id)
REFERENCES platform_product_categories(platform_id, id) ON DELETE SET NULL,
CHECK (resolved_at IS NULL OR resolved_at >= created_at)
);
DO $$
DECLARE table_name TEXT;
BEGIN
FOREACH table_name IN ARRAY ARRAY[
'product_platform_profiles',
'product_variant_platform_listings',
'product_attributes',
'product_unmapped_attributes'
] 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 $$;