438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
export type ProductAiSourceAttribute = {
|
|
key: string;
|
|
name: string;
|
|
value: string;
|
|
};
|
|
|
|
export type ProductAiSourceCategory = {
|
|
key: string;
|
|
name: string;
|
|
};
|
|
|
|
export type ProductAiSourceMaterial = {
|
|
title: string | null;
|
|
description: string | null;
|
|
price: { amount: string; currency_code: string } | null;
|
|
brand: string | null;
|
|
attributes: ProductAiSourceAttribute[];
|
|
categories: ProductAiSourceCategory[];
|
|
source_snapshot_revision: number;
|
|
};
|
|
|
|
export type ProductAiSourceField =
|
|
'title' | 'description' | 'price' | 'brand' | 'attributes' | 'categories';
|
|
|
|
export type ProductAiSourceMaterialState = {
|
|
latest: ProductAiSourceMaterial;
|
|
overridden_fields: ProductAiSourceField[];
|
|
changed_fields: ProductAiSourceField[];
|
|
base_source_snapshot_revision: number | null;
|
|
has_source_updates: boolean;
|
|
};
|
|
|
|
export type ProductAiInput = {
|
|
source_material: ProductAiSourceMaterial;
|
|
default_physical_spec: PhysicalSpec;
|
|
default_package_spec: PackageSpec;
|
|
variants: Array<{
|
|
variant_id: string;
|
|
display_name: string | null;
|
|
specification_values: SpecificationValue[];
|
|
effective_physical_spec: PhysicalSpec;
|
|
effective_package_spec: PackageSpec;
|
|
}>;
|
|
};
|
|
|
|
export type ProductEditorCategoryOption = {
|
|
id: string;
|
|
parent_id: string | null;
|
|
external_category_id: string;
|
|
external_type_id: string | null;
|
|
name: string;
|
|
path: string;
|
|
is_active: boolean;
|
|
};
|
|
|
|
export type ProductAttributeSchema = {
|
|
platform_attribute_id: string;
|
|
complex_id: string;
|
|
name: string;
|
|
value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary';
|
|
is_required: boolean;
|
|
is_collection: boolean;
|
|
max_value_count: number;
|
|
dictionary_id: string | null;
|
|
is_aspect: boolean;
|
|
};
|
|
|
|
export type ProductAttributeDictionaryValue = {
|
|
value_id: string;
|
|
value: string;
|
|
};
|
|
|
|
/**
|
|
* 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 = {
|
|
collection_item_id: string;
|
|
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;
|
|
};
|
|
default_physical_spec: PhysicalSpec;
|
|
default_package_spec: PackageSpec;
|
|
};
|
|
profile: {
|
|
id: string;
|
|
revision: number;
|
|
platform_id: string;
|
|
market_code: string | null;
|
|
fulfillment: string;
|
|
target_store: { id: string; name: string; currency_code: string };
|
|
available_target_stores: Array<{
|
|
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;
|
|
name: string | null;
|
|
path: string | null;
|
|
};
|
|
listing_summary: {
|
|
status: 'not_listed' | 'partially_listed' | 'listed';
|
|
detail: string;
|
|
};
|
|
};
|
|
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;
|
|
specification_values: SpecificationValue[];
|
|
physical_spec: VariantPhysicalSpec;
|
|
package_spec: VariantPackageSpec;
|
|
effective_physical_spec: PhysicalSpec;
|
|
effective_package_spec: PackageSpec;
|
|
listing: {
|
|
id: string;
|
|
offer_id: string | null;
|
|
status: string;
|
|
selling_price: string | null;
|
|
list_price: string | null;
|
|
currency_code: string | null;
|
|
};
|
|
/** Ordered final image assets for this platform listing. Source URLs
|
|
* are preview-only evidence and are never used for publishing. */
|
|
media: Array<{
|
|
asset_id: string;
|
|
content_type: 'image/jpeg' | 'image/png' | 'image/webp';
|
|
byte_size: number;
|
|
width_px: number | null;
|
|
height_px: number | null;
|
|
source_url: 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;
|
|
}>;
|
|
source_material: ProductAiSourceMaterial;
|
|
source_material_state: ProductAiSourceMaterialState;
|
|
ai_input: ProductAiInput;
|
|
warnings: Array<{ code: string; message: string }>;
|
|
};
|
|
|
|
export type SpecStatus = 'missing' | 'ambiguous' | 'confirmed';
|
|
export type PhysicalSpec = {
|
|
length_mm: string | null;
|
|
width_mm: string | null;
|
|
height_mm: string | null;
|
|
net_weight_g: string | null;
|
|
status: SpecStatus;
|
|
};
|
|
export type PackageSpec = {
|
|
length_mm: string | null;
|
|
width_mm: string | null;
|
|
height_mm: string | null;
|
|
shipping_weight_g: string | null;
|
|
status: SpecStatus;
|
|
};
|
|
export type VariantPhysicalSpec = Omit<PhysicalSpec, 'status'> & {
|
|
status: SpecStatus | null;
|
|
};
|
|
export type VariantPackageSpec = Omit<PackageSpec, 'status'> & {
|
|
status: SpecStatus | null;
|
|
};
|
|
export type SpecificationValue = {
|
|
axis_key: string;
|
|
value_key: string;
|
|
value_name: string;
|
|
};
|
|
|
|
const uuid = z.uuid();
|
|
const nullableText = (max: number) => z.string().trim().max(max).nullable();
|
|
const nullableDecimal = z
|
|
.string()
|
|
.regex(/^\d+(\.\d{1,3})?$/)
|
|
.refine((value) => Number(value) > 0, '必须大于 0')
|
|
.nullable();
|
|
const specStatus = z.enum(['missing', 'ambiguous', 'confirmed']);
|
|
const physicalSpec = z
|
|
.object({
|
|
length_mm: nullableDecimal,
|
|
width_mm: nullableDecimal,
|
|
height_mm: nullableDecimal,
|
|
net_weight_g: nullableDecimal,
|
|
status: specStatus,
|
|
})
|
|
.strict();
|
|
const packageSpec = z
|
|
.object({
|
|
length_mm: nullableDecimal,
|
|
width_mm: nullableDecimal,
|
|
height_mm: nullableDecimal,
|
|
shipping_weight_g: nullableDecimal,
|
|
status: specStatus,
|
|
})
|
|
.strict();
|
|
const variantPhysicalSpec = physicalSpec
|
|
.extend({ status: specStatus.nullable() })
|
|
.strict();
|
|
const variantPackageSpec = packageSpec
|
|
.extend({ status: specStatus.nullable() })
|
|
.strict();
|
|
const specificationValue = z
|
|
.object({
|
|
axis_key: z.string().trim().min(1).max(100),
|
|
value_key: z.string().trim().min(1).max(200),
|
|
value_name: z.string().trim().min(1).max(300),
|
|
})
|
|
.strict();
|
|
|
|
const productAttributeDraft = z
|
|
.object({
|
|
scope: z.enum(['common', 'variant']),
|
|
variant_id: uuid.nullable(),
|
|
platform_attribute_id: z.string().trim().min(1).max(200),
|
|
complex_id: z.string().trim().min(1).max(100),
|
|
values: z.array(z.string().trim().min(1).max(2_000)).min(1).max(100),
|
|
language_code: z.string().trim().min(2).max(20).nullable(),
|
|
source: z
|
|
.enum(['manual', 'source_capture', 'ai_suggestion'])
|
|
.default('manual'),
|
|
confirmation_status: z
|
|
.enum(['missing', 'ambiguous', 'confirmed'])
|
|
.default('confirmed'),
|
|
})
|
|
.strict()
|
|
.superRefine((attribute, context) => {
|
|
if (attribute.scope === 'common' && attribute.variant_id !== null) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
path: ['variant_id'],
|
|
message: '通用属性不能关联 SKU',
|
|
});
|
|
}
|
|
if (attribute.scope === 'variant' && attribute.variant_id === null) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
path: ['variant_id'],
|
|
message: 'SKU 属性必须关联 SKU',
|
|
});
|
|
}
|
|
});
|
|
|
|
export const productAiSourceMaterialSchema = z
|
|
.object({
|
|
title: nullableText(1_000),
|
|
description: nullableText(100_000),
|
|
price: z
|
|
.object({
|
|
amount: z.string().regex(/^\d+(\.\d{1,4})?$/),
|
|
currency_code: z.string().length(3).toUpperCase(),
|
|
})
|
|
.strict()
|
|
.nullable(),
|
|
brand: nullableText(300),
|
|
attributes: z
|
|
.array(
|
|
z
|
|
.object({
|
|
key: z.string().trim().min(1).max(200),
|
|
name: z.string().trim().min(1).max(300),
|
|
value: z.string().max(2_000),
|
|
})
|
|
.strict(),
|
|
)
|
|
.max(500),
|
|
categories: z
|
|
.array(
|
|
z
|
|
.object({
|
|
key: z.string().trim().min(1).max(300),
|
|
name: z.string().trim().min(1).max(500),
|
|
})
|
|
.strict(),
|
|
)
|
|
.max(50),
|
|
source_snapshot_revision: z.number().int().positive(),
|
|
})
|
|
.strict();
|
|
|
|
export const productAiSourceFieldSchema = z.enum([
|
|
'title',
|
|
'description',
|
|
'price',
|
|
'brand',
|
|
'attributes',
|
|
'categories',
|
|
]);
|
|
|
|
export const productAiSourceMaterialOverridesSchema =
|
|
productAiSourceMaterialSchema
|
|
.omit({ source_snapshot_revision: true })
|
|
.partial()
|
|
.strict();
|
|
|
|
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
|
|
export const saveProductEditorDraftRequestSchema = z
|
|
.object({
|
|
ai_source_material: productAiSourceMaterialSchema,
|
|
ai_source_overridden_fields: z
|
|
.array(productAiSourceFieldSchema)
|
|
.max(6)
|
|
.refine(
|
|
(fields) => new Set(fields).size === fields.length,
|
|
'覆盖字段不能重复',
|
|
),
|
|
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),
|
|
default_physical_spec: physicalSpec,
|
|
default_package_spec: packageSpec,
|
|
})
|
|
.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),
|
|
store_product_category_id: uuid.nullable().optional(),
|
|
category_source: z
|
|
.enum(['source_capture', 'ai_suggestion', 'manual'])
|
|
.nullable()
|
|
.optional(),
|
|
})
|
|
.strict(),
|
|
attributes: z.array(productAttributeDraft).max(1_000).optional(),
|
|
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),
|
|
specification_values: z
|
|
.array(specificationValue)
|
|
.max(20),
|
|
purchase_cost_cny: z
|
|
.string()
|
|
.regex(/^\d+(\.\d{1,4})?$/)
|
|
.nullable(),
|
|
physical_spec: variantPhysicalSpec,
|
|
package_spec: variantPackageSpec,
|
|
offer_id: nullableText(500),
|
|
media_asset_ids: z
|
|
.array(uuid)
|
|
.max(15)
|
|
.refine(
|
|
(ids) => new Set(ids).size === ids.length,
|
|
'SKU 图片不能重复',
|
|
)
|
|
.default([]),
|
|
})
|
|
.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 }>;
|
|
};
|