feat(edit): 编辑页上报ozon 开发
This commit is contained in:
@@ -178,6 +178,45 @@ export type ProductPublishPreflight = {
|
||||
refresh_runs: ProductCatalogRefreshRun[];
|
||||
};
|
||||
|
||||
export type ProductPublishItemStatus = 'queued' | 'submitting' | 'polling' | 'imported' | 'failed' | 'skipped' | 'reconciling';
|
||||
export type ProductPublishJob = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
store_id: string;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'partially_succeeded' | 'failed' | 'reconciling';
|
||||
item_count: number;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
items: Array<{
|
||||
id: string;
|
||||
variant_id: string;
|
||||
display_name: string | null;
|
||||
offer_id: string;
|
||||
status: ProductPublishItemStatus;
|
||||
platform_product_id: string | null;
|
||||
errors: Array<{ code: string; field?: string; message: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 发布快照只保存确定的素材身份,私有下载凭证在 Worker 提交前生成。 */
|
||||
export type OzonPublishItemSnapshot = {
|
||||
offer_id: string;
|
||||
name: string;
|
||||
description_category_id: number;
|
||||
type_id: number;
|
||||
price: string;
|
||||
old_price?: string;
|
||||
currency_code: string;
|
||||
depth: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dimension_unit: 'mm';
|
||||
weight: number;
|
||||
weight_unit: 'g';
|
||||
attributes: Array<{ id: number; complex_id: number; values: Array<{ value: string; dictionary_value_id?: number }> }>;
|
||||
media: Array<{ asset_id: string; checksum_sha256: string; bucket: string; object_key: string; storage_provider: string; content_type: string }>;
|
||||
};
|
||||
|
||||
/** 商品编辑入口与完整读模型;真正的发布 job DTO 仍在后续纵向任务中定义。 */
|
||||
export type ProductEditorContext = {
|
||||
collection_item_id: string;
|
||||
@@ -218,6 +257,7 @@ export type ProductEditorSnapshot = {
|
||||
currency_code: string;
|
||||
}>;
|
||||
title: string | null;
|
||||
title_mode: 'common' | 'variant';
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
category: {
|
||||
@@ -479,7 +519,7 @@ export const productPublishPreflightRequestSchema = z
|
||||
variant_ids: z
|
||||
.array(uuid)
|
||||
.min(1)
|
||||
.max(100)
|
||||
.max(50)
|
||||
.refine((ids) => new Set(ids).size === ids.length, 'SKU 不能重复'),
|
||||
})
|
||||
.strict();
|
||||
@@ -488,6 +528,9 @@ export type ProductPublishPreflightRequest = z.infer<
|
||||
typeof productPublishPreflightRequestSchema
|
||||
>;
|
||||
|
||||
export const createProductPublishJobRequestSchema = productPublishPreflightRequestSchema;
|
||||
export type CreateProductPublishJobRequest = ProductPublishPreflightRequest;
|
||||
|
||||
/** 商品、发布配置与 SKU(含报价确认意图)原子保存。 */
|
||||
export const saveProductEditorDraftRequestSchema = z
|
||||
.object({
|
||||
@@ -517,7 +560,8 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
.object({
|
||||
target_store_id: uuid,
|
||||
fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']),
|
||||
title: nullableText(1_000),
|
||||
title: nullableText(500),
|
||||
title_mode: z.enum(['common', 'variant']).optional(),
|
||||
description: nullableText(100_000),
|
||||
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
||||
platform_product_category_id: uuid.nullable().optional(),
|
||||
@@ -557,14 +601,14 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d{1,2})?$/)
|
||||
.nullable(),
|
||||
title: nullableText(1_000).optional(),
|
||||
title: nullableText(500).optional(),
|
||||
tags: z
|
||||
.array(z.string().trim().min(1).max(100))
|
||||
.max(30),
|
||||
description: nullableText(100_000),
|
||||
physical_spec: variantPhysicalSpec,
|
||||
package_spec: variantPackageSpec,
|
||||
offer_id: nullableText(500),
|
||||
offer_id: nullableText(50),
|
||||
media_asset_ids: z
|
||||
.array(uuid)
|
||||
.max(30)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 俄文标题可由商品通用值或逐 SKU 值填写;空草稿也需要保存模式。
|
||||
ALTER TABLE product_platform_profiles
|
||||
ADD COLUMN title_mode TEXT NOT NULL DEFAULT 'common'
|
||||
CHECK (title_mode IN ('common', 'variant'));
|
||||
|
||||
-- 既有 SKU 标题覆盖保留为逐 SKU 模式,不清除已有商品或 SKU 文案。
|
||||
UPDATE product_platform_profiles p
|
||||
SET title_mode = 'variant'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM product_variant_platform_listings l
|
||||
JOIN product_variants v ON v.id = l.product_variant_id
|
||||
WHERE l.profile_id = p.id AND v.deleted_at IS NULL AND nullif(btrim(l.title), '') IS NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 发布批次与每个 SKU 的不可变适配快照均沿用商品、配置和工作空间归属链。
|
||||
CREATE TABLE product_publish_jobs (
|
||||
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,
|
||||
store_id UUID NOT NULL,
|
||||
product_revision BIGINT NOT NULL,
|
||||
profile_revision BIGINT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','running','succeeded','partially_succeeded','failed','reconciling')),
|
||||
item_count INTEGER NOT NULL CHECK (item_count BETWEEN 1 AND 50),
|
||||
ozon_task_id TEXT,
|
||||
submit_started_at TIMESTAMPTZ,
|
||||
poll_count INTEGER NOT NULL DEFAULT 0,
|
||||
lease_until TIMESTAMPTZ,
|
||||
lease_token UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
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 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, id, product_id, profile_id, platform_id, store_id)
|
||||
);
|
||||
CREATE INDEX ix_product_publish_jobs_profile ON product_publish_jobs(workspace_id, profile_id, created_at DESC);
|
||||
|
||||
CREATE TABLE product_publish_job_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
job_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
listing_id UUID NOT NULL,
|
||||
variant_id UUID NOT NULL,
|
||||
display_name TEXT,
|
||||
offer_id TEXT NOT NULL CHECK (char_length(offer_id) BETWEEN 1 AND 50),
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','submitting','polling','imported','failed','skipped','reconciling')),
|
||||
snapshot JSONB NOT NULL CHECK (jsonb_typeof(snapshot) = 'object'),
|
||||
platform_product_id TEXT,
|
||||
errors JSONB NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(errors) = 'array'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, job_id, product_id, profile_id, platform_id, store_id)
|
||||
REFERENCES product_publish_jobs(workspace_id, id, product_id, profile_id, platform_id, store_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, listing_id, product_id, profile_id, variant_id)
|
||||
REFERENCES product_variant_platform_listings(workspace_id, id, product_id, profile_id, product_variant_id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, job_id, variant_id)
|
||||
);
|
||||
CREATE INDEX ix_product_publish_job_items_job ON product_publish_job_items(workspace_id, job_id);
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY['product_publish_jobs','product_publish_job_items'] 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 $$;
|
||||
|
||||
-- 成功导入身份不可被草稿写入覆盖;进行中 SKU 的删除和型号变更也不能绕过 API。
|
||||
CREATE FUNCTION guard_first_publish_identity() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'products' THEN
|
||||
IF NEW.model IS DISTINCT FROM OLD.model AND EXISTS (
|
||||
SELECT 1 FROM product_variant_platform_listings WHERE product_id = OLD.id AND status IN ('imported','submitting')
|
||||
) THEN RAISE EXCEPTION 'PRODUCT_MODEL_LOCKED' USING ERRCODE = '23514'; END IF;
|
||||
ELSIF TG_TABLE_NAME = 'product_platform_profiles' THEN
|
||||
IF NEW.target_store_id IS DISTINCT FROM OLD.target_store_id AND EXISTS (
|
||||
SELECT 1 FROM product_variant_platform_listings WHERE profile_id = OLD.id AND status = 'submitting'
|
||||
) THEN RAISE EXCEPTION 'PUBLISH_STORE_LOCKED' USING ERRCODE = '23514'; END IF;
|
||||
ELSIF TG_TABLE_NAME = 'product_variants' THEN
|
||||
IF NEW.deleted_at IS DISTINCT FROM OLD.deleted_at AND EXISTS (
|
||||
SELECT 1 FROM product_variant_platform_listings WHERE product_variant_id = OLD.id AND status IN ('imported','submitting')
|
||||
) THEN RAISE EXCEPTION 'PUBLISH_VARIANT_LOCKED' USING ERRCODE = '23514'; END IF;
|
||||
ELSIF OLD.status = 'imported' AND (
|
||||
NEW.status IS DISTINCT FROM OLD.status OR NEW.offer_id IS DISTINCT FROM OLD.offer_id
|
||||
OR NEW.store_id IS DISTINCT FROM OLD.store_id OR NEW.platform_product_id IS DISTINCT FROM OLD.platform_product_id
|
||||
OR NEW.first_imported_at IS DISTINCT FROM OLD.first_imported_at
|
||||
) THEN RAISE EXCEPTION 'SKU_ALREADY_IMPORTED' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER product_model_first_publish_guard BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION guard_first_publish_identity();
|
||||
CREATE TRIGGER profile_store_first_publish_guard BEFORE UPDATE ON product_platform_profiles FOR EACH ROW EXECUTE FUNCTION guard_first_publish_identity();
|
||||
CREATE TRIGGER variant_first_publish_guard BEFORE UPDATE ON product_variants FOR EACH ROW EXECUTE FUNCTION guard_first_publish_identity();
|
||||
CREATE TRIGGER listing_first_publish_guard BEFORE UPDATE ON product_variant_platform_listings FOR EACH ROW EXECUTE FUNCTION guard_first_publish_identity();
|
||||
|
||||
CREATE FUNCTION guard_publish_snapshot() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
IF NEW.snapshot IS DISTINCT FROM OLD.snapshot OR NEW.offer_id IS DISTINCT FROM OLD.offer_id
|
||||
OR NEW.job_id IS DISTINCT FROM OLD.job_id OR NEW.listing_id IS DISTINCT FROM OLD.listing_id
|
||||
OR NEW.variant_id IS DISTINCT FROM OLD.variant_id OR NEW.workspace_id IS DISTINCT FROM OLD.workspace_id
|
||||
OR NEW.product_id IS DISTINCT FROM OLD.product_id OR NEW.profile_id IS DISTINCT FROM OLD.profile_id
|
||||
OR NEW.platform_id IS DISTINCT FROM OLD.platform_id OR NEW.store_id IS DISTINCT FROM OLD.store_id
|
||||
THEN RAISE EXCEPTION 'PUBLISH_SNAPSHOT_IMMUTABLE' USING ERRCODE = '23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER publish_snapshot_guard BEFORE UPDATE ON product_publish_job_items FOR EACH ROW EXECUTE FUNCTION guard_publish_snapshot();
|
||||
|
||||
-- 快照创建时必须与当时的店铺一致;后续失败草稿换店不应改写历史快照。
|
||||
CREATE FUNCTION guard_publish_item_creation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
DECLARE batch_size INTEGER;
|
||||
BEGIN
|
||||
SELECT item_count INTO batch_size FROM product_publish_jobs WHERE workspace_id=NEW.workspace_id AND id=NEW.job_id FOR UPDATE;
|
||||
IF NOT EXISTS(SELECT 1 FROM product_variant_platform_listings WHERE workspace_id=NEW.workspace_id AND id=NEW.listing_id AND store_id=NEW.store_id AND platform_id=NEW.platform_id)
|
||||
THEN RAISE EXCEPTION 'PUBLISH_LISTING_STORE_MISMATCH' USING ERRCODE='23514'; END IF;
|
||||
IF (SELECT count(*) FROM product_publish_job_items WHERE workspace_id=NEW.workspace_id AND job_id=NEW.job_id) >= batch_size
|
||||
THEN RAISE EXCEPTION 'PUBLISH_ITEM_LIMIT_EXCEEDED' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER publish_item_creation_guard BEFORE INSERT ON product_publish_job_items FOR EACH ROW EXECUTE FUNCTION guard_publish_item_creation();
|
||||
|
||||
|
||||
-- 批次身份与版本属于历史事实,只允许 Worker 推进执行状态与租约。
|
||||
CREATE FUNCTION guard_publish_job_identity() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
IF ROW(NEW.workspace_id,NEW.product_id,NEW.profile_id,NEW.platform_id,NEW.store_id,NEW.product_revision,NEW.profile_revision,NEW.item_count)
|
||||
IS DISTINCT FROM ROW(OLD.workspace_id,OLD.product_id,OLD.profile_id,OLD.platform_id,OLD.store_id,OLD.product_revision,OLD.profile_revision,OLD.item_count)
|
||||
OR (OLD.ozon_task_id IS NOT NULL AND NEW.ozon_task_id IS DISTINCT FROM OLD.ozon_task_id)
|
||||
THEN RAISE EXCEPTION 'PUBLISH_JOB_IMMUTABLE' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER publish_job_identity_guard BEFORE UPDATE ON product_publish_jobs FOR EACH ROW EXECUTE FUNCTION guard_publish_job_identity();
|
||||
Reference in New Issue
Block a user