Files
atlas-seller-kit/packages/contracts/src/product-editor.ts
T
2026-09-11 10:36:25 +08:00

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 }>;
};