feat(product-edit):商品编辑页初版
This commit is contained in:
@@ -3,3 +3,4 @@ export * from './auth';
|
||||
export * from './stores';
|
||||
export * from './watermarks';
|
||||
export * from './media';
|
||||
export * from './product-editor';
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* M3.0 editor entry and read-model contracts. Draft mutation, pricing, and
|
||||
* publishing DTOs intentionally stay out of this first editor slice.
|
||||
*/
|
||||
export type ProductEditorContext = {
|
||||
collection_item_id: string;
|
||||
product_id: string;
|
||||
profile_id: string;
|
||||
target_store: { id: string; name: string; currency_code: string };
|
||||
editor_path: string;
|
||||
};
|
||||
|
||||
export type ProductEditorSnapshot = {
|
||||
product: {
|
||||
id: string;
|
||||
revision: number;
|
||||
common: {
|
||||
source_title: string | null;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
barcode: string | null;
|
||||
purchase_url: string | null;
|
||||
supply_note: string | null;
|
||||
};
|
||||
};
|
||||
profile: {
|
||||
id: string;
|
||||
revision: number;
|
||||
platform_id: string;
|
||||
market_code: string | null;
|
||||
fulfillment: string;
|
||||
target_store: { id: string; name: string; currency_code: string };
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
category: {
|
||||
id: string | null;
|
||||
platform_type_id: string | null;
|
||||
source: string | null;
|
||||
};
|
||||
};
|
||||
variants: Array<{
|
||||
id: string;
|
||||
source_variant_key: string | null;
|
||||
source_sku: string | null;
|
||||
display_name: string | null;
|
||||
sort_order: number;
|
||||
purchase_cost_cny: string | null;
|
||||
listing: {
|
||||
id: string;
|
||||
offer_id: string | null;
|
||||
status: string;
|
||||
selling_price: string | null;
|
||||
list_price: string | null;
|
||||
currency_code: string | null;
|
||||
};
|
||||
}>;
|
||||
attributes: Array<{
|
||||
id: string;
|
||||
scope: 'common' | 'variant';
|
||||
variant_id: string | null;
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
values: unknown;
|
||||
language_code: string | null;
|
||||
source: string;
|
||||
confirmation_status: string;
|
||||
}>;
|
||||
unmapped_attributes: Array<{
|
||||
id: string;
|
||||
variant_id: string | null;
|
||||
original_platform_category_id: string | null;
|
||||
original_platform_attribute_id: string;
|
||||
original_complex_id: string;
|
||||
original_name: string;
|
||||
values: unknown;
|
||||
language_code: string | null;
|
||||
reason: string;
|
||||
status: string;
|
||||
}>;
|
||||
warnings: Array<{ code: string; message: string }>;
|
||||
};
|
||||
|
||||
const uuid = z.uuid();
|
||||
const nullableText = (max: number) => z.string().trim().max(max).nullable();
|
||||
|
||||
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
|
||||
export const saveProductEditorDraftRequestSchema = z.object({
|
||||
common: z.object({
|
||||
source_title: nullableText(1_000),
|
||||
brand: nullableText(300),
|
||||
model: nullableText(300),
|
||||
barcode: nullableText(300),
|
||||
purchase_url: z.url().nullable(),
|
||||
supply_note: nullableText(4_000),
|
||||
}).strict(),
|
||||
profile: z.object({
|
||||
target_store_id: uuid,
|
||||
fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']),
|
||||
title: nullableText(1_000),
|
||||
description: nullableText(100_000),
|
||||
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
||||
}).strict(),
|
||||
variants: z.array(z.object({
|
||||
id: uuid.nullable(),
|
||||
client_key: z.string().trim().min(1).max(100).nullable(),
|
||||
copy_from_variant_id: uuid.nullable().optional(),
|
||||
display_name: nullableText(1_000),
|
||||
source_sku: nullableText(500),
|
||||
purchase_cost_cny: z.string().regex(/^\d+(\.\d{1,4})?$/).nullable(),
|
||||
offer_id: nullableText(500),
|
||||
}).strict().superRefine((variant, context) => {
|
||||
if (!variant.id && !variant.client_key) {
|
||||
context.addIssue({ code: 'custom', path: ['client_key'], message: '新 SKU 必须提供 client_key' });
|
||||
}
|
||||
})).min(1).max(100),
|
||||
}).strict();
|
||||
|
||||
export type SaveProductEditorDraftRequest = z.infer<typeof saveProductEditorDraftRequestSchema>;
|
||||
export type SaveProductEditorDraftResponse = {
|
||||
product_revision: number;
|
||||
profile_revision: number;
|
||||
created_variants: Array<{ client_key: string; id: string }>;
|
||||
warnings: Array<{ code: string; message: string }>;
|
||||
};
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user