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 & { status: SpecStatus | null; }; export type VariantPackageSpec = Omit & { 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 }>; };