diff --git a/.env.example b/.env.example index c481472..4584363 100644 --- a/.env.example +++ b/.env.example @@ -93,3 +93,15 @@ DEEPSEEK_MODEL_NAME=deepseek-flash # 单张 AI 图片编辑使用 Pcore 的 gpt-image-2 异步图片接口;根地址和 /v1 地址均可,仅 Worker 读取。 PCORE_BASE_URL=https://pcore.ai/v1 PCORE_API_KEY= + +# 首次上架:开发/测试 auto 允许,生产 auto 关闭;完成 VAT 与七牛联调后显式 true。 +OZON_PUBLISH_ENABLED=auto +# 与素材实际入库环境一致,生产设置 qiniu_kodo。 +OBJECT_STORAGE_PROVIDER=local_minio +# 七牛绑定的公网 HTTPS 下载域名,与 S3 服务 endpoint 分离。 +OZON_IMAGE_DOWNLOAD_ORIGIN= +# 图片签名初始有效期 7 天;生产值须通过异步/延迟抓图验收。 +OZON_IMAGE_URL_TTL_SECONDS=604800 +# 可复用对象存储凭据;分离授权时填写专用签名凭据。 +QINIU_ACCESS_KEY= +QINIU_SECRET_KEY= diff --git a/apps/api/src/config/app-config.module.ts b/apps/api/src/config/app-config.module.ts index 869b0ea..fd4041c 100644 --- a/apps/api/src/config/app-config.module.ts +++ b/apps/api/src/config/app-config.module.ts @@ -25,6 +25,8 @@ const environmentSchema = z.object({ .enum(['development', 'test', 'production']) .default('development'), PRICING_ALLOW_DRAFT_RATES: z.enum(['true', 'false', 'auto']).default('auto'), + OZON_PUBLISH_ENABLED: z.enum(['true', 'false', 'auto']).default('auto'), + OBJECT_STORAGE_PROVIDER: z.enum(['local_minio', 'qiniu_kodo']).default('local_minio'), API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200), API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'), WEB_ORIGIN: z.url().default('http://localhost:8100'), diff --git a/apps/api/src/media/media.service.ts b/apps/api/src/media/media.service.ts index acf9acd..7d1c72c 100644 --- a/apps/api/src/media/media.service.ts +++ b/apps/api/src/media/media.service.ts @@ -91,7 +91,7 @@ export class MediaService { `INSERT INTO atlas.media_upload_intents (id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type, expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id) - VALUES ($1, current_setting('app.workspace_id')::uuid, $2, 'local_minio', + VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $10, $3, $4, $5, $6, $7, $8, $9)`, [ id, @@ -103,6 +103,7 @@ export class MediaService { input.checksum_sha256, expiresAt, userId, + this.config.OBJECT_STORAGE_PROVIDER, ], ); const url = await getSignedUrl( @@ -219,7 +220,7 @@ export class MediaService { `INSERT INTO atlas.media_assets (id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px) - VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio', $2, $3, $4, $5, $6, $7, $8)`, + VALUES ($1, current_setting('app.workspace_id')::uuid, $9, $2, $3, $4, $5, $6, $7, $8)`, [ assetId, intent.bucket, @@ -229,6 +230,7 @@ export class MediaService { inspected.checksum, inspected.width, inspected.height, + intent.storage_provider, ], ); } @@ -364,12 +366,12 @@ export class MediaService { `INSERT INTO atlas.media_assets (id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px) - VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio', + VALUES ($1, current_setting('app.workspace_id')::uuid, $9, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (workspace_id, checksum_sha256) DO NOTHING RETURNING id`, [assetId, this.config.OBJECT_STORAGE_BUCKET, objectKey, image.contentType, - image.bytes.length, checksum, image.width, image.height], + image.bytes.length, checksum, image.width, image.height, this.config.OBJECT_STORAGE_PROVIDER], ); assetId = inserted.rows[0]?.id ?? (await tx.query<{ id: string }>( 'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1', diff --git a/apps/api/src/product-editor/product-editor.controller.ts b/apps/api/src/product-editor/product-editor.controller.ts index 8eac262..afba49a 100644 --- a/apps/api/src/product-editor/product-editor.controller.ts +++ b/apps/api/src/product-editor/product-editor.controller.ts @@ -14,6 +14,7 @@ import { import { z } from 'zod'; import { productPublishPreflightRequestSchema, + createProductPublishJobRequestSchema, requestProductAiTaskSchema, saveProductEditorDraftRequestSchema, saveProductSourceDescriptionRequestSchema, @@ -242,6 +243,30 @@ export class ProductEditorController { return result.body; } + /** 同一事务重新校验并创建首次发布批次。 */ + @Post('product-platform-profiles/:profileId/publish-jobs') + async createPublishJob(@Param('profileId') profileId: string, @Body() body: unknown, + @Headers('x-atlas-platform-id') platformId: string | undefined, @Headers('idempotency-key') key: string | undefined, + @Headers('if-match') revision: string | undefined, @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) { + const parsed = createProductPublishJobRequestSchema.safeParse(body); + if (!parsed.success) throw new ApiError(422, 'VALIDATION_FAILED', '请选择 1–50 个不同的待上架 SKU'); + const result = await this.editor.createPublishJob(workspace.id, request.auth.userId, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId), requiredUuid('Idempotency-Key', key), parseEditorRevision(revision), parsed.data.variant_ids); + response.status(result.status); return result.body; + } + + /** 恢复编辑页最近发布任务,刷新页面后也能查看进度。 */ + @Get('product-platform-profiles/:profileId/publish-jobs') + async publishJobs(@Param('profileId') profileId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) { + return {data: await this.editor.listPublishJobs(workspace.id, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId))}; + } + + /** 查询当前租户及平台下的逐 SKU 发布结果。 */ + @Get('product-publish-jobs/:jobId') + async publishJob(@Param('jobId') jobId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) { + return {data: await this.editor.readPublishJob(workspace.id, requiredUuid('publish_job_id', jobId), requiredUuid('X-Atlas-Platform-Id', platformId))}; + } + /** 幂等地创建所选类目属性定义的刷新任务。 */ @Post('product-platform-profiles/:profileId/attribute-schema-syncs') async attributeSchemaSync( diff --git a/apps/api/src/product-editor/product-editor.models.ts b/apps/api/src/product-editor/product-editor.models.ts index 9b8367f..cfdd5c8 100644 --- a/apps/api/src/product-editor/product-editor.models.ts +++ b/apps/api/src/product-editor/product-editor.models.ts @@ -36,6 +36,7 @@ export type ProfileRow = { market_code: string | null; fulfillment: string; title: string | null; + title_mode: 'common' | 'variant'; description: string | null; tags: unknown; platform_product_category_id: string | null; @@ -62,7 +63,7 @@ export type OperationRow = { /** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */ export const profileSelect = `SELECT p.id, p.product_id, p.revision, p.platform_id, p.market_code, - p.fulfillment, p.title, p.description, p.tags, p.platform_product_category_id, + p.fulfillment, p.title, p.title_mode, p.description, p.tags, p.platform_product_category_id, p.store_product_category_id, p.platform_type_id, p.category_source, category.name AS category_name, category.path AS category_path, p.target_store_id, s.name AS store_name, diff --git a/apps/api/src/product-editor/product-editor.publish-preflight.ts b/apps/api/src/product-editor/product-editor.publish-preflight.ts index 23bcde6..7e8c8d8 100644 --- a/apps/api/src/product-editor/product-editor.publish-preflight.ts +++ b/apps/api/src/product-editor/product-editor.publish-preflight.ts @@ -382,6 +382,10 @@ export class ProductEditorPublishPreflightOperations { [profileId, variantIds], ); const used = new Map(); + // 内建品牌等字典字段也需要平台候选值,不能绕过发布目录有效期检查。 + for (const definition of schema.filter(item => item.editor_field && item.value_type === 'dictionary' && item.dictionary_id)) { + used.set(definition.attribute_id, {attribute_id: definition.attribute_id, platform_attribute_id: definition.platform_attribute_id, name: definition.name}); + } for (const attribute of attributes.rows) { if (!hasStringValue(attribute.values)) continue; const definition = definitions.get( diff --git a/apps/api/src/product-editor/product-editor.publish.ts b/apps/api/src/product-editor/product-editor.publish.ts new file mode 100644 index 0000000..13d8e56 --- /dev/null +++ b/apps/api/src/product-editor/product-editor.publish.ts @@ -0,0 +1,516 @@ +import { Decimal } from 'decimal.js'; +import { randomUUID } from 'node:crypto'; +import type { + OzonPublishItemSnapshot, + ProductAttributeSchema, + ProductPublishJob, + ProductEditorValidation, +} from '@atlas/contracts'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import type { IdempotencyService } from '../common/idempotency.service.js'; +import type { ProfileRow, ProductRow } from './product-editor.models.js'; + +type Access = { + profile: ( + tx: DatabaseTransaction, + id: string, + platformId: string, + ) => Promise; + product: ( + tx: DatabaseTransaction, + id: string, + lock?: boolean, + ) => Promise; + schema: ( + tx: DatabaseTransaction, + platformId: string, + categoryId: string, + ) => Promise; + validate: ( + tx: DatabaseTransaction, + profileId: string, + platformId: string, + ids: string[], + ) => Promise; +}; +type Listing = { + id: string; + variant_id: string; + display_name: string | null; + offer_id: string; + store_id: string; + status: string; + title: string | null; + tags: string[]; + description: string | null; + selling_price: string; + list_price: string | null; + currency_code: string | null; + depth: string; + width: string; + height: string; + weight: string; +}; +type Attribute = { + scope: string; + product_variant_id: string | null; + platform_attribute_id: string; + complex_id: string; + values: string[]; +}; + +/** 发布创建只读取本地事实,所有远端访问由 Worker 执行。 */ +export class ProductEditorPublishOperations { + constructor( + private readonly database: Database, + private readonly idempotency: IdempotencyService, + private readonly access: Access, + private readonly enabled: boolean, + ) {} + + async create( + workspaceId: string, + userId: string, + profileId: string, + platformId: string, + key: string, + expected: { product: number; profile: number }, + ids: string[], + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path: `/api/product-platform-profiles/${profileId}/publish-jobs`, + key, + request: { platformId, expected, variant_ids: ids }, + }, + async (tx) => { + if (!this.enabled) + throw new ApiError( + 503, + 'OZON_PUBLISH_DISABLED', + '生产上架尚未开启,请先完成店铺 VAT 与图片交付验收', + ); + if ( + !ids.length || + ids.length > 50 || + new Set(ids).size !== ids.length + ) + throw new ApiError( + 422, + 'PUBLISH_VARIANTS_INVALID', + '本次最多上架 50 个 SKU,请使用部分上架分批提交', + ); + const initial = await this.access.profile( + tx, + profileId, + platformId, + ); + const product = await this.access.product( + tx, + initial.product_id, + true, + ); + await tx.query( + 'SELECT id FROM atlas.product_platform_profiles WHERE id = $1 FOR UPDATE', + [profileId], + ); + const profile = await this.access.profile( + tx, + profileId, + platformId, + ); + if ( + Number(product.revision) !== expected.product || + Number(profile.revision) !== expected.profile + ) + throw new ApiError( + 409, + 'VERSION_CONFLICT', + '商品资料已更新,请重新加载后上架', + ); + const store = await tx.query<{ code: string }>( + `SELECT p.code FROM atlas.stores s JOIN atlas.platforms p ON p.id=s.platform_id WHERE s.id=$1 AND s.platform_id=$2 AND s.deleted_at IS NULL AND s.status='active' AND s.credential_status='valid' FOR SHARE OF s`, + [profile.target_store_id, platformId], + ); + if (store.rows[0]?.code.toUpperCase() !== 'OZON') + throw new ApiError( + 422, + 'STORE_UNAVAILABLE', + '当前 Ozon 店铺不可用,请检查授权', + ); + const category = await tx.query<{ + external_category_id: string; + external_type_id: string; + }>( + `SELECT external_category_id, external_type_id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active FOR SHARE`, + [profile.platform_product_category_id, platformId], + ); + if (!category.rows[0]) + throw new ApiError( + 422, + 'CATEGORY_REQUIRED', + '请选择当前有效的 Ozon 产品类目', + ); + const listings = await tx.query( + `SELECT l.id, v.id AS variant_id, v.display_name, l.offer_id, l.store_id, l.status, l.title, l.tags, l.description, l.selling_price::text, l.list_price::text, l.currency_code, + coalesce(v.package_length_mm,p.default_package_length_mm)::text AS depth, + coalesce(v.package_width_mm,p.default_package_width_mm)::text AS width, + coalesce(v.package_height_mm,p.default_package_height_mm)::text AS height, + coalesce(v.shipping_weight_g,p.default_shipping_weight_g)::text AS weight + FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id JOIN atlas.products p ON p.id=v.product_id + WHERE l.profile_id=$1 AND v.product_id=$2 AND v.deleted_at IS NULL AND v.id=ANY($3::uuid[]) ORDER BY v.sort_order,v.id FOR UPDATE OF l,v`, + [profileId, product.id, ids], + ); + if (listings.rows.length !== ids.length) + throw new ApiError( + 422, + 'PUBLISH_VARIANTS_INVALID', + '所选 SKU 不属于当前商品或已经删除', + ); + if (listings.rows.some((l) => l.status === 'imported')) + throw new ApiError( + 422, + 'SKU_ALREADY_IMPORTED', + '所选 SKU 已在当前店铺导入,请前往在线商品查看', + ); + if (listings.rows.some((l) => l.status === 'submitting')) + throw new ApiError( + 409, + 'SKU_IMPORT_IN_PROGRESS', + '所选 SKU 正在提交或等待平台对账,请等待任务完成', + ); + if ( + listings.rows.some( + (l) => l.store_id !== profile.target_store_id, + ) + ) + throw new ApiError( + 409, + 'PUBLISH_STORE_CHANGED', + 'SKU 店铺与目标店铺不一致,请保存草稿后重试', + ); + const validation = await this.access.validate( + tx, + profileId, + platformId, + ids, + ); + if (!validation.ready) + throw new ApiError( + 422, + 'PUBLISH_VALIDATION_FAILED', + '请完善待上架商品资料', + [ + ...validation.profile.issues, + ...validation.variants.flatMap((v) => v.issues), + ], + ); + const schema = await this.access.schema( + tx, + platformId, + profile.platform_product_category_id!, + ); + const attributes = await tx.query( + `SELECT scope, product_variant_id, platform_attribute_id, complex_id, values FROM atlas.product_attributes WHERE profile_id=$1 AND confirmation_status='confirmed' AND (scope='common' OR product_variant_id=ANY($2::uuid[]))`, + [profileId, ids], + ); + const dictionaries = await tx.query<{ + attribute_id: string; + external_value_id: string; + value: string; + }>( + `SELECT attribute_id, external_value_id, value FROM atlas.platform_catalog_dictionary_values WHERE attribute_id=ANY($1::uuid[]) AND is_active`, + [ + schema + .filter((d) => d.dictionary_id) + .map((d) => d.attribute_id), + ], + ); + const builtinDictionaryIds = schema + .filter( + (d) => + d.editor_field && + d.value_type === 'dictionary' && + d.dictionary_id, + ) + .map((d) => d.attribute_id); + if (builtinDictionaryIds.length) { + const states = await tx.query<{ usable: boolean }>( + `SELECT state.last_success_at IS NOT NULL AND state.last_success_at >= now()-make_interval(days=>policy.dictionary_expire_days) AS usable FROM unnest($2::uuid[]) used(id) JOIN atlas.platform_catalog_refresh_policies policy ON policy.platform_id=$1 LEFT JOIN atlas.platform_catalog_resource_states state ON state.platform_id=$1 AND state.scope_key='dictionary:' || used.id::text`, + [platformId, builtinDictionaryIds], + ); + if ( + states.rows.length !== builtinDictionaryIds.length || + states.rows.some((s) => !s.usable) + ) + throw new ApiError( + 422, + 'PUBLISH_VALIDATION_FAILED', + '品牌等内建字段的 Ozon 候选值需要更新,请重新预检', + ); + } + const snapshots: OzonPublishItemSnapshot[] = []; + for (const listing of listings.rows) { + const media = await tx.query< + OzonPublishItemSnapshot['media'][number] + >( + `SELECT a.id AS asset_id,a.checksum_sha256,a.bucket,a.object_key,a.storage_provider,a.content_type FROM atlas.product_variant_listing_media m JOIN atlas.media_assets a ON a.id=m.asset_id WHERE m.listing_id=$1 ORDER BY m.sort_order`, + [listing.id], + ); + if (!['not_listed', 'failed'].includes(listing.status)) + throw new ApiError( + 422, + 'SKU_NOT_PUBLISHABLE', + '所选 SKU 当前状态不允许首次上架', + ); + if ( + !media.rows.length || + media.rows.some( + (a) => + ![ + 'image/jpeg', + 'image/png', + 'image/webp', + ].includes(a.content_type), + ) + ) + throw new ApiError( + 422, + 'PUBLISH_IMAGE_INVALID', + 'SKU 图片必须为 JPG、PNG 或 WebP', + ); + const mapped: OzonPublishItemSnapshot['attributes'] = []; + for (const definition of schema) { + if ( + definition.editor_field === 'category' || + definition.editor_field === 'rich_content' + ) + continue; + let values: string[]; + if (definition.editor_field) { + const fields = { + brand: product.brand, + model: product.model, + tags: (listing.tags?.length + ? listing.tags + : profile.tags) as string[], + description: + listing.description ?? profile.description, + }; + const field = fields[definition.editor_field]; + values = Array.isArray(field) + ? field + : field + ? [field] + : []; + } else + values = + attributes.rows.find( + (a) => + a.platform_attribute_id === + definition.platform_attribute_id && + a.complex_id === + definition.complex_id && + (definition.is_aspect + ? a.product_variant_id === + listing.variant_id + : a.scope === 'common'), + )?.values ?? []; + values = values + .filter((v) => typeof v === 'string' && v.trim()) + .map((v) => v.trim()); + if (values.length > definition.max_value_count) + throw new ApiError( + 422, + 'PUBLISH_VALIDATION_FAILED', + `${definition.name}最多 ${definition.max_value_count} 个值`, + [ + { + field: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`, + code: 'ATTRIBUTE_VALUE_LIMIT', + message: `${definition.name}值数量超限`, + }, + ], + ); + if (!values.length) continue; + mapped.push({ + id: numberId(definition.platform_attribute_id), + complex_id: Number(definition.complex_id), + values: values.map((value) => { + if (definition.value_type !== 'dictionary') + return { value }; + const entry = dictionaries.rows.find( + (d) => + d.attribute_id === + definition.attribute_id && + (definition.editor_field + ? d.value === value || + d.external_value_id === value + : d.external_value_id === value), + ); + if (!entry) + throw new ApiError( + 422, + 'PUBLISH_VALIDATION_FAILED', + `${definition.name}未匹配有效 Ozon 候选值`, + [ + { + field: definition.editor_field + ? `common.${definition.editor_field}` + : `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`, + code: 'ATTRIBUTE_DICTIONARY_VALUE_INVALID', + message: `${definition.name}未匹配有效 Ozon 候选值`, + }, + ], + ); + return { + dictionary_value_id: numberId( + entry.external_value_id, + ), + value: entry.value, + }; + }), + }); + } + snapshots.push({ + offer_id: listing.offer_id, + name: (profile.title_mode === 'variant' + ? listing.title + : profile.title)!.trim(), + description_category_id: numberId( + category.rows[0].external_category_id, + ), + type_id: numberId(category.rows[0].external_type_id), + price: listing.selling_price, + ...(listing.list_price && + new Decimal(listing.list_price).greaterThan( + listing.selling_price, + ) + ? { old_price: listing.list_price! } + : {}), + currency_code: profile.currency_code, + depth: Number(listing.depth), + width: Number(listing.width), + height: Number(listing.height), + weight: Number(listing.weight), + dimension_unit: 'mm', + weight_unit: 'g', + attributes: mapped, + media: media.rows, + }); + } + const jobId = randomUUID(); + await tx.query( + `INSERT INTO atlas.product_publish_jobs(id,workspace_id,product_id,profile_id,platform_id,store_id,product_revision,profile_revision,item_count) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [ + jobId, + workspaceId, + product.id, + profileId, + platformId, + profile.target_store_id, + expected.product, + expected.profile, + ids.length, + ], + ); + for (let i = 0; i < listings.rows.length; i++) { + const listing = listings.rows[i]; + await tx.query( + `INSERT INTO atlas.product_publish_job_items(workspace_id,job_id,product_id,profile_id,platform_id,store_id,listing_id,variant_id,display_name,offer_id,snapshot) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)`, + [ + workspaceId, + jobId, + product.id, + profileId, + platformId, + profile.target_store_id, + listing.id, + listing.variant_id, + listing.display_name, + listing.offer_id, + JSON.stringify(snapshots[i]), + ], + ); + } + await tx.query( + `UPDATE atlas.product_variant_platform_listings SET status='submitting',last_error=NULL,revision=revision+1,updated_at=now() WHERE id=ANY($1::uuid[])`, + [listings.rows.map((l) => l.id)], + ); + await tx.query( + `INSERT INTO atlas.outbox_events(workspace_id,aggregate_type,aggregate_id,event_type,event_payload) VALUES($1,'product_publish_job',$2,'product_publish.requested',$3::jsonb)`, + [ + workspaceId, + jobId, + JSON.stringify({ publish_job_id: jobId }), + ], + ); + return { + status: 202, + body: { + data: { + publish_job: await readJob(tx, jobId, platformId), + }, + }, + }; + }, + ); + } + async read(workspaceId: string, id: string, platformId: string) { + return this.database.workspaceTransaction(workspaceId, (tx) => + readJob(tx, id, platformId), + ); + } + async list(workspaceId: string, profileId: string, platformId: string) { + return this.database.workspaceTransaction(workspaceId, async (tx) => { + await this.access.profile(tx, profileId, platformId); + const rows = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.product_publish_jobs WHERE profile_id=$1 AND platform_id=$2 ORDER BY created_at DESC,id DESC LIMIT 10', + [profileId, platformId], + ); + const jobs = []; + for (const row of rows.rows) + jobs.push(await readJob(tx, row.id, platformId)); + return jobs; + }); + } +} +async function readJob( + tx: DatabaseTransaction, + id: string, + platformId: string, +): Promise { + const rows = await tx.query( + `SELECT id,profile_id,store_id,status,item_count,created_at,completed_at FROM atlas.product_publish_jobs WHERE id=$1 AND platform_id=$2`, + [id, platformId], + ); + const job = rows.rows[0]; + if (!job) + throw new ApiError(404, 'PUBLISH_JOB_NOT_FOUND', '发布任务不存在'); + const items = await tx.query( + `SELECT id,variant_id,display_name,offer_id,status,platform_product_id,errors FROM atlas.product_publish_job_items WHERE job_id=$1 ORDER BY created_at,id`, + [id], + ); + return { + ...job, + created_at: new Date(job.created_at).toISOString(), + completed_at: job.completed_at + ? new Date(job.completed_at).toISOString() + : null, + items: items.rows, + }; +} +function numberId(value: string) { + const n = Number(value); + if (!Number.isSafeInteger(n) || n <= 0) + throw new ApiError( + 422, + 'OZON_ID_INVALID', + '平台目录标识无效,请刷新目录', + ); + return n; +} diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 4816465..765edd1 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -21,6 +21,7 @@ import { import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js'; import { bindEditorFields } from './product-editor.attribute-bindings.js'; import { ProductEditorValidationOperations } from './product-editor.validation.js'; +import { ProductEditorPublishOperations } from './product-editor.publish.js'; import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js'; import { capturedOzonCategorySignals, @@ -40,6 +41,7 @@ import { @Injectable() export class ProductEditorService { + private readonly publishing: ProductEditorPublishOperations; private readonly pricing: PricingService; private readonly catalog: ProductEditorCatalogOperations; private readonly validationOperations: ProductEditorValidationOperations; @@ -71,6 +73,12 @@ export class ProductEditorService { this.attributeSchemaForCategory(...args), }, ); + this.publishing = new ProductEditorPublishOperations(this.database, this.idempotency, { + profile: (...args) => this.requireProfileForPlatform(...args), + product: (...args) => this.requireProduct(...args), + schema: (...args) => this.attributeSchemaForCategory(...args), + validate: (...args) => this.validationOperations.validateTransaction(...args), + }, config.OZON_PUBLISH_ENABLED === 'true' || ((config.OZON_PUBLISH_ENABLED ?? 'auto') === 'auto' && config.NODE_ENV !== 'production')); this.publishPreflightOperations = new ProductEditorPublishPreflightOperations(this.database, { requireProfileForPlatform: (...args) => @@ -451,6 +459,7 @@ export class ProductEditorService { }, available_target_stores: stores.rows, title: profile.title, + title_mode: profile.title_mode, description: profile.description, tags: Array.isArray(profile.tags) ? profile.tags.filter( @@ -586,6 +595,12 @@ export class ProductEditorService { ); } + createPublishJob(workspaceId: string, userId: string, profileId: string, platformId: string, key: string, expected: { product: number; profile: number }, ids: string[]) { + return this.publishing.create(workspaceId, userId, profileId, platformId, key, expected, ids); + } + readPublishJob(workspaceId: string, id: string, platformId: string) { return this.publishing.read(workspaceId, id, platformId); } + listPublishJobs(workspaceId: string, profileId: string, platformId: string) { return this.publishing.list(workspaceId, profileId, platformId); } + /** 从平台级缓存查询当前类型属性的字典值。 */ async attributeDictionary( workspaceId: string, @@ -757,6 +772,12 @@ export class ProductEditorService { ], ); } + const lockedListings = await transaction.query<{ product_variant_id: string; status: string }>( + `SELECT product_variant_id,status FROM atlas.product_variant_platform_listings WHERE product_id=$1 AND status IN ('imported','submitting')`, [productId]); + if (lockedListings.rows.length && input.common.model !== product.model) throw new ApiError(409, 'PRODUCT_MODEL_LOCKED', '商品已有 SKU 导入或正在导入,型号不可修改'); + const profileLocked = await transaction.query(`SELECT id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND status='submitting' LIMIT 1`, [profile.id]); + if (profileLocked.rows.length && input.profile.target_store_id !== profile.target_store_id) throw new ApiError(409, 'PUBLISH_STORE_LOCKED', '当前商品有 SKU 正在导入或等待对账,暂不能更换目标店铺'); + if (lockedListings.rows.some(row => !input.variants.some(v => v.id === row.product_variant_id))) throw new ApiError(409, 'PUBLISH_VARIANT_LOCKED', '已导入或正在提交的 SKU 不可删除'); this.assertConfirmedSpecsComplete(input); await this.ensureVariantIdsBelongToProduct( transaction, @@ -788,14 +809,14 @@ export class ProductEditorService { SET target_store_id = $1, fulfillment = $2, title = $3, description = $4, tags = $5::jsonb, platform_product_category_id = $6, store_product_category_id = $7, platform_type_id = $8, - category_source = $9, + category_source = $9, title_mode = $14, revision = revision + 1, updated_at = now() WHERE id = $10 AND product_id = $11 AND platform_id = $12 AND revision = $13 RETURNING revision`, [ input.profile.target_store_id, input.profile.fulfillment, - input.profile.title, + input.profile.title_mode === 'variant' ? null : input.profile.title, input.profile.description, JSON.stringify(input.profile.tags), selectedCategory?.id ?? null, @@ -806,6 +827,7 @@ export class ProductEditorService { productId, platformId, expected.profile, + input.profile.title_mode ?? profile.title_mode, ], ); if (!updatedProfile.rows[0]) @@ -919,14 +941,14 @@ export class ProductEditorService { SET offer_id = $1, store_id = $2, tags = $3::jsonb, description = $4, title = CASE WHEN $5::boolean THEN $6 ELSE title END, revision = revision + 1, updated_at = now() - WHERE profile_id = $7 AND product_variant_id = $8 AND status <> 'imported'`, + WHERE profile_id = $7 AND product_variant_id = $8 AND status NOT IN ('imported','submitting')`, [ variant.offer_id, input.profile.target_store_id, JSON.stringify(variant.tags), variant.description, - variant.title !== undefined, - variant.title ?? null, + variant.title !== undefined || input.profile.title_mode === 'common', + input.profile.title_mode === 'common' ? null : (variant.title ?? null), profile.id, variant.id, ], @@ -1024,7 +1046,7 @@ export class ProductEditorService { variant.offer_id, JSON.stringify(variant.tags), variant.description, - variant.title ?? null, + input.profile.title_mode === 'common' ? null : (variant.title ?? null), ], ); } diff --git a/apps/api/src/product-editor/product-editor.validation.ts b/apps/api/src/product-editor/product-editor.validation.ts index 53fda0b..858570f 100644 --- a/apps/api/src/product-editor/product-editor.validation.ts +++ b/apps/api/src/product-editor/product-editor.validation.ts @@ -26,6 +26,7 @@ type ValidationAccess = { type VariantValidationRow = { id: string; display_name: string | null; + title: string | null; listing_status: string; offer_id: string | null; selling_price: string | null; @@ -79,101 +80,117 @@ export class ProductEditorValidationOperations { return this.database.workspaceTransaction( workspaceId, async (transaction) => { - const profile = await this.access.requireProfileForPlatform( + return this.validateTransaction( transaction, profileId, platformId, - ); - const product = await this.access.requireProduct( - transaction, - profile.product_id, - ); - const variants = await this.readVariants( - transaction, - profile.id, - profile.product_id, variantIds, ); - if (variantIds && variants.length !== new Set(variantIds).size) - throw new ApiError( - 422, - 'PUBLISH_VARIANTS_INVALID', - '待发布 SKU 不属于当前商品,或已经被删除', - ); - const attributes = await this.readAttributes( - transaction, - profile.id, - variantIds, - ); - const schemaUsable = profile.platform_product_category_id - ? await this.schemaUsable( - transaction, - profile.platform_id, - profile.platform_product_category_id, - ) - : false; - const schema = - profile.platform_product_category_id && schemaUsable - ? await this.access.attributeSchemaForCategory( - transaction, - profile.platform_id, - profile.platform_product_category_id, - ) - : []; - - const dictionaryIssues = schema.length - ? await this.dictionaryIssues( - transaction, - profile.platform_id, - schema, - attributes, - ) - : []; - - const profileIssues = this.profileIssues( - product, - profile, - schema, - attributes, - ); - profileIssues.push( - ...dictionaryIssues - .filter((item) => item.scope === 'common') - .map((item) => item.issue), - ); - const variantResults = variants.map((variant) => - this.variantResult( - variant, - schema, - attributes, - dictionaryIssues - .filter( - (item) => - item.scope === 'variant' && - item.variant_id === variant.id, - ) - .map((item) => item.issue), - ), - ); - const publishableVariants = variantResults.filter( - (variant) => variant.status !== 'not_publishable_again', - ); - - return { - ready: - profileIssues.length === 0 && - publishableVariants.length > 0 && - publishableVariants.every((variant) => variant.ready), - profile: { - ready: profileIssues.length === 0, - issues: profileIssues, - }, - variants: variantResults, - }; }, ); } + /** 创建发布任务必须在持有商品锁的同一事务内重算,防止校验与快照读到不同版本。 */ + async validateTransaction( + transaction: DatabaseTransaction, + profileId: string, + platformId: string, + variantIds?: string[], + ): Promise { + const profile = await this.access.requireProfileForPlatform( + transaction, + profileId, + platformId, + ); + const product = await this.access.requireProduct( + transaction, + profile.product_id, + ); + const variants = await this.readVariants( + transaction, + profile.id, + profile.product_id, + variantIds, + ); + if (variantIds && variants.length !== new Set(variantIds).size) + throw new ApiError( + 422, + 'PUBLISH_VARIANTS_INVALID', + '待发布 SKU 不属于当前商品,或已经被删除', + ); + const attributes = await this.readAttributes( + transaction, + profile.id, + variantIds, + ); + const schemaUsable = profile.platform_product_category_id + ? await this.schemaUsable( + transaction, + profile.platform_id, + profile.platform_product_category_id, + ) + : false; + const schema = + profile.platform_product_category_id && schemaUsable + ? await this.access.attributeSchemaForCategory( + transaction, + profile.platform_id, + profile.platform_product_category_id, + ) + : []; + + const dictionaryIssues = schema.length + ? await this.dictionaryIssues( + transaction, + profile.platform_id, + schema, + attributes, + ) + : []; + + const profileIssues = this.profileIssues( + product, + profile, + schema, + attributes, + ); + profileIssues.push( + ...dictionaryIssues + .filter((item) => item.scope === 'common') + .map((item) => item.issue), + ); + const variantResults = variants.map((variant) => + this.variantResult( + variant, + profile, + schema, + attributes, + dictionaryIssues + .filter( + (item) => + item.scope === 'variant' && + item.variant_id === variant.id, + ) + .map((item) => item.issue), + ), + ); + const publishableVariants = variantResults.filter( + (variant) => variant.status !== 'not_publishable_again', + ); + + return { + ready: + profileIssues.length === 0 && + publishableVariants.length > 0 && + publishableVariants.every((variant) => variant.ready), + profile: { + ready: profileIssues.length === 0, + issues: profileIssues, + }, + variants: variantResults, + }; + } + /** 草稿就绪校验不得把超过过期阈值的旧 schema 当成当前 Ozon 规则。 */ private async schemaUsable( transaction: DatabaseTransaction, @@ -181,7 +198,7 @@ export class ProductEditorValidationOperations { categoryId: string, ) { const result = await transaction.query<{ usable: boolean }>( - `SELECT state.last_success_at IS NOT NULL + `SELECT state.last_success_at IS NOT NULL AND state.is_complete AND state.last_success_at >= now() - make_interval(days => policy.attribute_expire_days) AS usable FROM atlas.platform_catalog_refresh_policies policy @@ -208,11 +225,23 @@ export class ProductEditorValidationOperations { field: 'common.source_title', message: '请填写中文商品名', }); - if (!profile.title?.trim()) + if (profile.title_mode === 'common' && !profile.title?.trim()) issues.push({ code: 'PUBLISH_TITLE_REQUIRED', field: 'profile.title', - message: '请填写俄文发布标题', + message: '请填写俄文标题', + }); + if (profile.title && [...profile.title].length > 500) + issues.push({ + code: 'TITLE_TOO_LONG', + field: 'profile.title', + message: '俄文标题最多 500 字符', + }); + if (profile.fulfillment !== 'rfbs') + issues.push({ + code: 'FULFILLMENT_UNSUPPORTED', + field: 'profile.fulfillment', + message: '首次上架当前仅支持跨境 rFBS', }); if (!product.model?.trim()) issues.push({ @@ -256,6 +285,7 @@ export class ProductEditorValidationOperations { /** 计算单个 SKU 的发布资格与缺失项。 */ private variantResult( variant: VariantValidationRow, + profile: ProfileRow, schema: ProductAttributeSchema[], attributes: AttributeValidationRow[], dictionaryIssues: ProductEditorValidation['profile']['issues'] = [], @@ -272,12 +302,36 @@ export class ProductEditorValidationOperations { const issues: ProductEditorValidation['variants'][number]['issues'] = [ ...dictionaryIssues, ]; + if (profile.title_mode === 'variant' && !variant.title?.trim()) + issues.push({ + code: 'PUBLISH_TITLE_REQUIRED', + field: `variants.${variant.id}.title`, + message: '请填写该 SKU 的俄文标题', + }); if (variant.listing_status === 'submitting') issues.push({ code: 'SKU_IMPORT_IN_PROGRESS', field: `variants.${variant.id}.listing`, message: '该 SKU 正在首次上架,请等待当前任务完成', }); + if (variant.title && [...variant.title].length > 500) + issues.push({ + code: 'TITLE_TOO_LONG', + field: `variants.${variant.id}.title`, + message: '俄文标题最多 500 字符', + }); + if (variant.offer_id && [...variant.offer_id].length > 50) + issues.push({ + code: 'OFFER_ID_TOO_LONG', + field: `variants.${variant.id}.offer_id`, + message: '卖家货号最多 50 字符', + }); + if (Number(variant.media_count) > 30) + issues.push({ + code: 'IMAGE_LIMIT_EXCEEDED', + field: `variants.${variant.id}.media_asset_ids`, + message: '每个 SKU 最多 30 张图片', + }); if (!variant.offer_id?.trim()) issues.push({ code: 'OFFER_ID_REQUIRED', @@ -290,7 +344,7 @@ export class ProductEditorValidationOperations { field: `variants.${variant.id}.pricing_selection`, message: '请按当前采购价和包装确认报价', }); - if (!variant.selling_price) + if (!variant.selling_price || Number(variant.selling_price) <= 0) issues.push({ code: 'PRICE_REQUIRED', field: `variants.${variant.id}.selling_price`, @@ -301,7 +355,15 @@ export class ProductEditorValidationOperations { !variant.package_length_mm || !variant.package_width_mm || !variant.package_height_mm || - !variant.shipping_weight_g + !variant.shipping_weight_g || + ![ + variant.package_length_mm, + variant.package_width_mm, + variant.package_height_mm, + variant.shipping_weight_g, + ].every( + (value) => Number.isInteger(Number(value)) && Number(value) > 0, + ) ) issues.push({ code: 'PACKAGE_SPEC_REQUIRED', @@ -354,7 +416,7 @@ export class ProductEditorValidationOperations { variantIds?: string[], ) { const variants = await transaction.query( - `SELECT v.id, v.display_name, listing.status AS listing_status, + `SELECT v.id, v.display_name, listing.title, listing.status AS listing_status, listing.offer_id, listing.selling_price::text, listing.pricing_valid, coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm, coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm, @@ -369,7 +431,7 @@ export class ProductEditorValidationOperations { LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id WHERE v.product_id = $2 AND v.deleted_at IS NULL AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[])) - GROUP BY v.id, v.display_name, listing.status, listing.offer_id, + GROUP BY v.id, v.display_name, listing.title, listing.status, listing.offer_id, listing.selling_price, listing.pricing_valid, product.default_package_length_mm, product.default_package_width_mm, product.default_package_height_mm, product.default_shipping_weight_g, product.package_spec_status diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index 81e527c..7b332ad 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -2129,7 +2129,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { ); }); - it('俄文发布标题为空时发布就绪校验提示必填,填写后移除提示', async () => { + it('俄文标题按通用或逐 SKU 模式校验,并持久保存切换结果', async () => { await database.workspaceTransaction(workspaceId, async (tx) => { const store = await tx.query<{ id: string }>( `SELECT id FROM atlas.stores @@ -2148,7 +2148,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { [platformId, `editor-title-${randomUUID()}`], ); }); - const created = await createCollectionItem('俄文发布标题校验商品'); + const created = await createCollectionItem('俄文标题校验商品'); const entered = await request( `/api/collection-items/${created.collectionItemId}/editor-contexts`, { @@ -2189,6 +2189,119 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { (issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED', ), ); + + const beforeModeSwitch = await request(`/api/products/${created.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + const snapshot = beforeModeSwitch.body.data; + assert.equal(snapshot.profile.title_mode, 'common'); + const draft = { + ai_source_material: snapshot.source_material, + ai_source_overridden_fields: snapshot.source_material_state.overridden_fields, + common: { + ...snapshot.product.common, + default_physical_spec: snapshot.product.default_physical_spec, + default_package_spec: snapshot.product.default_package_spec, + }, + profile: { + target_store_id: snapshot.profile.target_store.id, + fulfillment: snapshot.profile.fulfillment, + title: null as string | null, + title_mode: 'variant', + description: snapshot.profile.description, + tags: snapshot.profile.tags, + platform_product_category_id: snapshot.profile.category.id, + category_source: snapshot.profile.category.source, + }, + variants: snapshot.variants.map((variant: any) => ({ + id: variant.id, + client_key: null, + display_name: variant.display_name, + source_sku: variant.source_sku, + specification_values: variant.specification_values, + purchase_cost_cny: variant.purchase_cost_cny, + title: null, + tags: variant.tags, + description: variant.description, + physical_spec: variant.physical_spec, + package_spec: variant.package_spec, + offer_id: variant.listing.offer_id, + media_asset_ids: variant.media.map((item: { asset_id: string }) => item.asset_id), + })), + }; + const saveTitleMode = async (body: typeof draft, productRevision: number, profileRevision: number) => + request(`/api/products/${created.productId}/editor-draft`, { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${productRevision}:${profileRevision}"`, + }), + body: JSON.stringify(body), + }); + const variantMode = await saveTitleMode( + draft, + snapshot.product.revision, + snapshot.profile.revision, + ); + assert.equal(variantMode.response.status, 200, JSON.stringify(variantMode.body)); + const blankVariant = await request( + `/api/product-platform-profiles/${profileId}/validation`, + { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }, + ); + assert.ok( + !blankVariant.body.data.profile.issues.some( + (issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED', + ), + ); + assert.ok( + blankVariant.body.data.variants[0].issues.some( + (issue: { code: string; field: string }) => + issue.code === 'PUBLISH_TITLE_REQUIRED' && + issue.field === `variants.${snapshot.variants[0].id}.title`, + ), + ); + const afterVariantMode = await request(`/api/products/${created.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(afterVariantMode.body.data.profile.title_mode, 'variant'); + assert.equal(afterVariantMode.body.data.profile.title, null); + const filledVariant = await saveTitleMode( + { + ...draft, + variants: [{ ...draft.variants[0], title: 'Название SKU' }], + }, + afterVariantMode.body.data.product.revision, + afterVariantMode.body.data.profile.revision, + ); + assert.equal(filledVariant.response.status, 200, JSON.stringify(filledVariant.body)); + const readyTitle = await request( + `/api/product-platform-profiles/${profileId}/validation`, + { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }, + ); + assert.ok( + !readyTitle.body.data.variants[0].issues.some( + (issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED', + ), + ); + const afterFilledVariant = await request(`/api/products/${created.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + const commonMode = await saveTitleMode( + { + ...draft, + profile: { ...draft.profile, title: '通用俄文标题', title_mode: 'common' }, + variants: [{ ...draft.variants[0], title: null }], + }, + afterFilledVariant.body.data.product.revision, + afterFilledVariant.body.data.profile.revision, + ); + assert.equal(commonMode.response.status, 200, JSON.stringify(commonMode.body)); + const afterCommonMode = await request(`/api/products/${created.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(afterCommonMode.body.data.profile.title_mode, 'common'); + assert.equal(afterCommonMode.body.data.variants[0].title, null); }); it('包装重量模式独立保存,并允许 SKU 已确认重量继承通用尺寸', async () => { diff --git a/apps/api/test/product-publish.e2e.test.ts b/apps/api/test/product-publish.e2e.test.ts new file mode 100644 index 0000000..e98de2c --- /dev/null +++ b/apps/api/test/product-publish.e2e.test.ts @@ -0,0 +1,467 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import type { Database } from '@atlas/database'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import type { ProductPublishJob } from '@atlas/contracts'; +import type { S3Client } from '@aws-sdk/client-s3'; +import { beforeAll, afterAll, describe, it } from 'vitest'; +import { DATABASE } from '../src/database/database.module.js'; +import { createApplication } from '../src/main.js'; +import { + runProductPublish, + type PublishDependencies, +} from '../../worker/src/product-publish.js'; +import { + OzonRateLimitError, + OzonRetryableError, +} from '../../worker/src/ozon-client.js'; + +let app: NestExpressApplication, + db: Database, + base = '', + workspace = '', + token = '', + platform = '', + store = '', + category = ''; +const storage = {} as S3Client; +async function request( + path: string, + method = 'GET', + body?: unknown, + extra: Record = {}, +) { + const res = await fetch(`${base}/api${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-Atlas-Platform-Id': platform, + ...extra, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: res.status, body: (await res.json()) as any }; +} +async function fixture(count = 2) { + const created = await request( + '/collection-items', + 'POST', + { title: '发布测试商品' }, + { 'Idempotency-Key': randomUUID() }, + ); + assert.equal(created.status, 201, JSON.stringify(created)); + const product = created.body.data.product_id; + const entered = await request( + `/collection-items/${created.body.data.collection_item.id}/editor-contexts`, + 'POST', + {}, + { 'Idempotency-Key': randomUUID() }, + ); + assert.equal(entered.status, 201, JSON.stringify(entered)); + const profile = entered.body.data.profile_id; + const ids: string[] = []; + await db.workspaceTransaction(workspace, async (tx) => { + await tx.query( + `UPDATE atlas.products SET brand='Без бренда',model='TEST-MODEL',default_package_length_mm=240,default_package_width_mm=120,default_package_height_mm=60,default_shipping_weight_g=350,package_spec_status='confirmed' WHERE id=$1`, + [product], + ); + await tx.query( + `UPDATE atlas.product_platform_profiles SET title='Тестовый товар',fulfillment='rfbs',platform_product_category_id=$2,platform_type_id='1002' WHERE id=$1`, + [profile, category], + ); + const first = ( + await tx.query<{ id: string }>( + 'SELECT id FROM atlas.product_variants WHERE product_id=$1', + [product], + ) + ).rows[0].id; + ids.push(first); + for (let i = 1; i < count; i++) + ids.push( + ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.product_variants(workspace_id,product_id,display_name,sort_order) VALUES($1,$2,$3,$4) RETURNING id`, + [workspace, product, `SKU ${i}`, i], + ) + ).rows[0].id, + ); + for (let i = 0; i < ids.length; i++) { + const variant = ids[i]; + await tx.query( + `INSERT INTO atlas.product_variant_platform_listings(workspace_id,product_id,profile_id,platform_id,product_variant_id,store_id) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, + [workspace, product, profile, platform, variant, store], + ); + const quote = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.pricing_quotes(workspace_id,store_id,platform_id,product_id,profile_id,product_variant_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,input_snapshot,status,confirmed_at) VALUES($1,$2,$3,$4,$5,$6,'rfbs',20,0.350,24,12,6,12,'{}','confirmed',now()) RETURNING id`, + [workspace, store, platform, product, profile, variant], + ) + ).rows[0].id; + const listing = ( + await tx.query<{ id: string }>( + `UPDATE atlas.product_variant_platform_listings SET offer_id=$2,selling_price=1999.1234,list_price=2999.1234,currency_code='RUB',pricing_quote_id=$3,pricing_valid=true WHERE profile_id=$1 AND product_variant_id=$4 RETURNING id`, + [profile, `TEST-${randomUUID()}`, quote, variant], + ) + ).rows[0].id; + const asset = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.media_assets(workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256) VALUES($1,'qiniu_kodo','test-bucket',$2,'image/png',100,$3) RETURNING id`, + [workspace, randomUUID(), randomUUID()], + ) + ).rows[0].id; + await tx.query( + `INSERT INTO atlas.product_variant_listing_media(workspace_id,product_id,profile_id,listing_id,product_variant_id,asset_id,sort_order) VALUES($1,$2,$3,$4,$5,$6,0)`, + [workspace, product, profile, listing, variant, asset], + ); + } + }); + return { profile, product, ids }; +} +async function publish( + f: Awaited>, + ids = f.ids, + key = randomUUID(), +) { + return request( + `/product-platform-profiles/${f.profile}/publish-jobs`, + 'POST', + { variant_ids: ids }, + { 'Idempotency-Key': key, 'If-Match': '"1:1"' }, + ); +} +async function run( + job: ProductPublishJob, + requestFn: PublishDependencies['request'], + images: PublishDependencies['images'] = async () => [ + 'https://images.example.test/fixture.png', + ], +) { + await runProductPublish( + db, + storage, + { + eventId: randomUUID(), + workspaceId: workspace, + eventType: 'product_publish.requested', + payload: { publish_job_id: job.id }, + }, + { + request: requestFn, + images, + decrypt: () => 'fixture-key', + }, + ); + const response = await request(`/product-publish-jobs/${job.id}`); + assert.equal(response.status, 200); + return response.body.data as ProductPublishJob; +} + +describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { + beforeAll(async () => { + Object.assign(process.env, { + NODE_ENV: 'test', + SWAGGER_ENABLED: 'false', + LOG_LEVEL: 'error', + AUTH_REGISTER_IP_LIMIT: '100', + AUTH_REGISTER_EMAIL_LIMIT: '100', + OZON_PUBLISH_ENABLED: 'true', + }); + ({ app } = await createApplication()); + db = app.get(DATABASE); + await app.listen(0, '127.0.0.1'); + base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; + const registration = await fetch(`${base}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: `publish-${randomUUID()}@example.test`, + password: 'correct-horse-battery-staple-123', + password_confirmation: 'correct-horse-battery-staple-123', + }), + }); + const data = (await registration.json()) as any; + assert.equal(registration.status, 201, JSON.stringify(data)); + workspace = data.data.workspace.id; + token = data.data.access_token; + platform = (await request('/platforms')).body.data[0].id; + await db.workspaceTransaction(workspace, async (tx) => { + store = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'发布测试店铺',$3,'RUB',true,'active','valid') RETURNING id`, + [workspace, platform, randomUUID()], + ) + ).rows[0].id; + await tx.query( + `INSERT INTO atlas.store_secrets(workspace_id,store_id,kind,secret_ciphertext,secret_nonce,encryption_key_version,fingerprint,status) VALUES($1,$2,'seller_api_key',$3,$4,'v1','fixture','valid')`, + [workspace, store, Buffer.from('fixture'), Buffer.alloc(12)], + ); + category = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'1002','发布测试类目','发布测试类目') RETURNING id`, + [platform, `${Date.now()}`], + ) + ).rows[0].id; + await tx.query( + `INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1)`, + [platform, category], + ); + await tx.query( + `INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope_key,scope,category_id,last_success_at,is_complete) VALUES($1,$2,'attributes',$3,now(),true)`, + [platform, `attributes:${category}`, category], + ); + }); + }); + afterAll(async () => { + await app?.close(); + }); + + it('预检、并发幂等、精度快照、Outbox 与跨租户隔离', async () => { + const f = await fixture(); + const key = randomUUID(); + const [a, b] = await Promise.all([ + publish(f, undefined, key), + publish(f, undefined, key), + ]); + assert.equal(a.status, 202, JSON.stringify(a)); + assert.deepEqual(a.body, b.body); + const job = a.body.data.publish_job as ProductPublishJob; + assert.equal(job.items.length, 2); + const facts = await db.workspaceTransaction(workspace, async (tx) => ({ + items: ( + await tx.query( + 'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', + [job.id], + ) + ).rows, + events: ( + await tx.query( + 'SELECT event_payload FROM atlas.outbox_events WHERE aggregate_id=$1', + [job.id], + ) + ).rows, + })); + assert.equal(facts.events.length, 1); + assert.deepEqual(facts.events[0].event_payload, { + publish_job_id: job.id, + }); + assert.equal(facts.items[0].snapshot.price, '1999.1234'); + assert.equal(facts.items[0].snapshot.depth, 240); + assert.ok(!('vat' in facts.items[0].snapshot)); + assert.ok(!('images' in facts.items[0].snapshot)); + const other = randomUUID(); + assert.equal( + ( + await db.workspaceTransaction(other, (tx) => + tx.query( + 'SELECT id FROM atlas.product_publish_jobs WHERE id=$1', + [job.id], + ), + ) + ).rows.length, + 0, + ); + assert.equal( + ( + await request( + `/product-publish-jobs/${job.id}`, + 'GET', + undefined, + { 'X-Atlas-Platform-Id': randomUUID() }, + ) + ).status, + 404, + ); + assert.equal( + (await publish(f)).body.error.code, + 'SKU_IMPORT_IN_PROGRESS', + ); + assert.equal( + ( + await publish(f, [ + ...Array.from({ length: 51 }, () => randomUUID()), + ]) + ).status, + 422, + ); + assert.equal( + ( + await request( + `/product-platform-profiles/${f.profile}/publish-jobs`, + 'POST', + { variant_ids: f.ids }, + { 'Idempotency-Key': randomUUID(), 'If-Match': '"2:1"' }, + ) + ).body.error.code, + 'VERSION_CONFLICT', + ); + }); + it('只校验所选 SKU;不就绪的未选 SKU 不阻断部分上架', async () => { + const f = await fixture(); + await db.workspaceTransaction(workspace, (tx) => + tx.query( + `UPDATE atlas.product_variant_platform_listings SET offer_id=NULL WHERE profile_id=$1 AND product_variant_id=$2`, + [f.profile, f.ids[1]], + ), + ); + const all = await publish(f); + assert.equal(all.status, 422); + assert.equal(all.body.error.code, 'PUBLISH_VALIDATION_FAILED'); + const partial = await publish(f, [f.ids[0]]); + assert.equal(partial.status, 202, JSON.stringify(partial)); + assert.equal(partial.body.data.publish_job.item_count, 1); + }); + it('混合结果持久化,成功锁定型号/货号,重复消息不重复上报', async () => { + const f = await fixture(); + let job = (await publish(f)).body.data.publish_job as ProductPublishJob; + let imports = 0; + job = await run(job, async (_client, _key, path, body) => { + if (path === '/v3/product/info/list') return { items: [] }; + assert.equal(path, '/v3/product/import'); + imports++; + const items = body.items as any[]; + assert.equal(items.length, 2); + assert.equal(items[0].price, '1999.1234'); + assert.ok(!('vat' in items[0])); + assert.ok(!('barcode' in items[0])); + assert.ok(!('description' in items[0])); + return { result: { task_id: 123456 } }; + }); + assert.equal(job.items[0].status, 'polling'); + const offers = job.items.map((i) => i.offer_id); + job = await run(job, async () => ({ + result: { + items: [ + { + offer_id: offers[0], + product_id: 101, + status: 'imported', + errors: [], + }, + { + offer_id: offers[1], + status: 'failed', + errors: [ + { + code: 'BAD_IMAGE', + message: + '失败 https://private.example.test/a?token=hidden', + }, + ], + }, + ], + }, + })); + assert.equal(job.status, 'partially_succeeded'); + assert.equal( + job.items.find((i) => i.status === 'imported')?.platform_product_id, + '101', + ); + assert.ok(!JSON.stringify(job).includes('token=')); + await assert.rejects( + db.workspaceTransaction(workspace, (tx) => + tx.query( + `UPDATE atlas.products SET model='CHANGED' WHERE id=$1`, + [f.product], + ), + ), + /PRODUCT_MODEL_LOCKED/, + ); + await assert.rejects( + db.workspaceTransaction(workspace, (tx) => + tx.query( + `UPDATE atlas.product_variant_platform_listings SET offer_id='CHANGED' WHERE profile_id=$1 AND status='imported'`, + [f.profile], + ), + ), + /SKU_ALREADY_IMPORTED/, + ); + await run(job, async () => { + imports++; + throw new Error('终态不应再访问平台'); + }); + assert.equal(imports, 1); + }); + it('响应丢失后只按货号对账,绝不重复导入', async () => { + const f = await fixture(1); + let job = (await publish(f)).body.data.publish_job as ProductPublishJob; + let imports = 0; + job = await run(job, async (_c, _k, path) => { + if (path === '/v3/product/info/list') return { items: [] }; + imports++; + throw new OzonRetryableError('response lost'); + }); + assert.equal(job.status, 'reconciling'); + job = await run(job, async (_c, _k, path) => { + assert.equal(path, '/v3/product/info/list'); + return { items: [] }; + }); + assert.equal(job.status, 'reconciling'); + assert.equal(imports, 1); + job = await run(job, async () => ({ + items: [{ offer_id: job.items[0].offer_id, id: 999 }], + })); + assert.equal(job.status, 'succeeded'); + assert.equal(imports, 1); + }); + it('任一图片准备失败时整批不提交,并隐藏存储路径', async () => { + const f = await fixture(2); + const created = (await publish(f)).body.data + .publish_job as ProductPublishJob; + let prepared = 0; + const job = await run( + created, + async (_c, _k, path) => { + assert.equal(path, '/v3/product/info/list'); + return { items: [] }; + }, + async () => { + if (++prepared === 2) + throw new Error('AccessDenied secret/object/key'); + return ['https://images.example.test/fixture.png']; + }, + ); + assert.equal(job.status, 'failed'); + assert.ok(job.items.every((i) => i.status === 'failed')); + assert.ok(!JSON.stringify(job).includes('secret/object')); + const source = await db.workspaceTransaction(workspace, (tx) => + tx.query( + 'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1', + [job.id], + ), + ); + assert.equal(source.rows[0].submit_started_at, null); + }); + it('远端已有同货号时跳过,不调用更新接口;429 延迟后允许提交', async () => { + const existing = await fixture(1); + let job = (await publish(existing)).body.data + .publish_job as ProductPublishJob; + job = await run(job, async (_c, _k, path) => { + assert.equal(path, '/v3/product/info/list'); + return { items: [{ offer_id: job.items[0].offer_id, id: 88 }] }; + }); + assert.equal(job.items[0].status, 'skipped'); + assert.equal(job.items[0].errors[0].code, 'OZON_OFFER_ALREADY_EXISTS'); + const f = await fixture(1); + job = (await publish(f)).body.data.publish_job as ProductPublishJob; + job = await run(job, async (_c, _k, path) => { + if (path === '/v3/product/info/list') return { items: [] }; + throw new OzonRateLimitError(120); + }); + assert.equal(job.items[0].status, 'queued'); + const source = await db.workspaceTransaction(workspace, (tx) => + tx.query( + 'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1', + [job.id], + ), + ); + assert.equal(source.rows[0].submit_started_at, null); + job = await run(job, async (_c, _k, path) => + path === '/v3/product/info/list' + ? { items: [] } + : { result: { task_id: 987 } }, + ); + assert.equal(job.items[0].status, 'polling'); + }); +}); diff --git a/apps/web/HANDOFF.md b/apps/web/HANDOFF.md index 5eb3441..3c1e200 100644 --- a/apps/web/HANDOFF.md +++ b/apps/web/HANDOFF.md @@ -1,19 +1,27 @@ # Web 前端交接:商品编辑器与 AI 填报 -更新日期:2026-09-23 +更新日期:2026-09-30 本文记录 Web 编辑页的现行状态;跨领域总体进度以 [`../../docs/project/HANDOFF.md`](../../docs/project/HANDOFF.md) 的 1.39 为准。开始继续开发前请查看仓库根目录的 `git status --short`,保留目前 API、Worker、contracts、迁移及 Web 中未提交和未跟踪的改动。 -## 当前状态(2026-09-23) +## 当前状态(2026-09-30) -- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 生图、首次上架仍是占位/不可用入口。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。 +- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 套图生成仍是占位入口;首次上架已接入,见下方记录。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。 - 全局 AI 只回填 SPU 发布标题、标签、描述和中文对照,以及合格的类目、型号、未导入 SKU 货号与有证据的包装建议。类目有多个候选时,「确认 AI 推荐类目」弹窗用单选卡片让用户选择;选择前不自动写入类目。类目一旦确定,页面按现有机制加载动态属性,但不调用 AI 填写 Ozon 类目属性。 -- SPU 的「中文商品名」和「俄文发布标题」在页面中均标为必填;发布就绪校验也要求俄文标题。标题、标签、描述各有中文对照入口;标题旁有加号,可开启 SKU 标题覆盖。SKU 默认继承 SPU 文案,用户可在当前 SKU 弹窗中单独生成、预览并明确应用,不因全局 AI 填报而批量覆盖 SKU。 +- SPU 的「中文商品名」和「俄文标题」标示首次上架必填,草稿保存允许留空;俄文标题可在通用与按 SKU 设置间切换并保存模式,按 SKU 设置时清空、禁用 SPU 标题并在各 SKU 填写。标题、标签、描述各有中文对照入口;用户可在当前 SKU 弹窗中单独生成、预览并明确应用,不因全局 AI 填报而批量覆盖 SKU。 - SKU 的 Ozon `is_aspect` 字段现在直接放在展开卡片的「SKU 信息」中,不另显示「Ozon 变体属性」标题。产品类目 Cascader 的清除事件按空类目处理,避免读取 `undefined.at`。来源采集信息侧栏仍展示来源资料与差异处理。 - 关键组件:`ProductMainInfoSection.tsx`(名称、类目、SPU 标题等)、`ProductAttributeSection.tsx`(SPU 属性、标签与描述)、`ProductVariantSection.tsx`(SKU 信息及动态变体字段)、`ProductCopyAiDialogs.tsx`(SKU AI 生成与中俄对照)。商品编辑流程编排仍主要在 `ProductEditPage.tsx`。 下一轮前端回归应覆盖固定 footer 在不同视口/侧栏状态下不遮挡内容、类目清除和候选确认、AI 任务失败/部分成功/版本过期、SPU/SKU 文案继承与中文对照保存后重读。俄文禁汉字的最终拦截在 Worker,旧文案不会由前端自动清洗。本次更新只修改交接文档,未重新运行浏览器回归。 +## 2026-09-30 首次上架 + +- `ProductPublishActions.tsx` 提供「上架至 OZON」「部分上架」,沿用父页面草稿保存与版本锁,按指定 SKU 调用预检;超过 50 个需分批选择,任一待提交项失败不创建任务。 +- 选择表禁选已导入/进行中 SKU,并显示原因;确认摘要列出店铺、货号、售价和图片数量。任务列表恢复最近任务,轮询后仅合并 listing 状态,保留未保存表单。 +- 发布标题最多 500、货号最多 50、SKU 图片最多 30;中文来源商品名仍为 1000。已导入或进行中锁定型号,进行中锁定换店;其他草稿资料可以编辑。 +- Web 类型检查及构建通过。隔离数据库页面验证了禁选、全部/部分预检、缺货号阻断、确认提交和刷新恢复;没有向真实 Ozon 店铺导入。 +- VAT 首期省略且生产发布默认关闭;真实店铺与七牛图片交付待验收。后端契约见 `docs/api/product-editor-and-publishing.md` 第 7 节。 + ## 2026-09-11 历史前端记录(以下内容仅供追溯) ### 商品编辑器 diff --git a/apps/web/src/components/PackageIntegerInput.tsx b/apps/web/src/components/PackageIntegerInput.tsx index d127391..e3d4fae 100644 --- a/apps/web/src/components/PackageIntegerInput.tsx +++ b/apps/web/src/components/PackageIntegerInput.tsx @@ -3,6 +3,13 @@ import type { InputProps } from 'antd'; const digitsOnly = /^\d+$/; +/** 仅去掉数值本身为整数的历史小数尾零;非整数原值留给用户核对。 */ +export function normalizePackageInteger(value: string | null | undefined): string { + const text = value ?? ''; + if (!/^\d+(?:\.0+)?$/.test(text)) return text; + return text.replace(/\.0+$/, '').replace(/^0+(?=\d)/, ''); +} + export function isPositivePackageInteger(value: unknown): value is string { return typeof value === 'string' && digitsOnly.test(value) && Number(value) > 0; } diff --git a/apps/web/src/components/pricing/PricingCalculator.tsx b/apps/web/src/components/pricing/PricingCalculator.tsx index 36f5ef2..05a81b2 100644 --- a/apps/web/src/components/pricing/PricingCalculator.tsx +++ b/apps/web/src/components/pricing/PricingCalculator.tsx @@ -30,7 +30,7 @@ import type { import { pricingPreviewRequestSchema } from '@atlas/contracts'; import { pricingApi } from '@/services/pricing'; import { apiErrorCode, apiErrorMessage } from '@/services/api'; -import { PackageIntegerInput } from '@/components/PackageIntegerInput'; +import { PackageIntegerInput, normalizePackageInteger } from '@/components/PackageIntegerInput'; import { percentToRate, rateToPercent } from './pricingPercentage'; import { pricingInputProblem } from './pricingInputValidation'; @@ -326,7 +326,16 @@ export function PricingCalculator({ const [policyName, setPolicyName] = useState(null); const [policySaving, setPolicySaving] = useState(false); const [localCatalog, setLocalCatalog] = useState(catalog); - const [input, setInput] = useState(() => structuredClone(initial)); + const [input, setInput] = useState(() => ({ + ...structuredClone(initial), + package: { + ...initial.package, + shipping_weight_g: normalizePackageInteger(initial.package.shipping_weight_g), + length_mm: normalizePackageInteger(initial.package.length_mm), + width_mm: normalizePackageInteger(initial.package.width_mm), + height_mm: normalizePackageInteger(initial.package.height_mm), + }, + })); const [preview, setPreview] = useState(null); const [selected, setSelected] = useState(null); const [error, setError] = useState(''); diff --git a/apps/web/src/components/pricing/SkuPricing.tsx b/apps/web/src/components/pricing/SkuPricing.tsx index 241329e..91d0a01 100644 --- a/apps/web/src/components/pricing/SkuPricing.tsx +++ b/apps/web/src/components/pricing/SkuPricing.tsx @@ -14,6 +14,7 @@ import type { import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts'; import { pricingApi } from '@/services/pricing'; import { apiErrorCode, apiErrorMessage } from '@/services/api'; +import { normalizePackageInteger } from '@/components/PackageIntegerInput'; import { defaultPricing, PricingCalculator } from './PricingCalculator'; import { pricingInputProblem } from './pricingInputValidation'; type Values = SaveProductEditorDraftRequest; @@ -27,10 +28,10 @@ export function skuPricingInput( base = values.common.default_package_spec, pkg = v.package_spec; const effectivePackage = { - shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '', - length_mm: pkg.length_mm ?? base.length_mm ?? '', - width_mm: pkg.width_mm ?? base.width_mm ?? '', - height_mm: pkg.height_mm ?? base.height_mm ?? '', + shipping_weight_g: normalizePackageInteger(pkg.shipping_weight_g ?? base.shipping_weight_g), + length_mm: normalizePackageInteger(pkg.length_mm ?? base.length_mm), + width_mm: normalizePackageInteger(pkg.width_mm ?? base.width_mm), + height_mm: normalizePackageInteger(pkg.height_mm ?? base.height_mm), }; return { store_id: values.profile.target_store_id, @@ -538,7 +539,14 @@ export function SkuPricing({ ]); setModal({ ...modal, - package: { ...base, status: base.status }, + package: { + ...base, + shipping_weight_g: normalizePackageInteger(base.shipping_weight_g), + length_mm: normalizePackageInteger(base.length_mm), + width_mm: normalizePackageInteger(base.width_mm), + height_mm: normalizePackageInteger(base.height_mm), + status: base.status, + }, }); }} > diff --git a/apps/web/src/pages/products/ProductEditPage.css b/apps/web/src/pages/products/ProductEditPage.css index 4198ddc..0283c9d 100644 --- a/apps/web/src/pages/products/ProductEditPage.css +++ b/apps/web/src/pages/products/ProductEditPage.css @@ -47,6 +47,88 @@ gap: 8px; white-space: nowrap; } +.editor-publish-status-slot:not(:empty) { + margin-top: 14px; +} +.editor-publish-status { + display: flex; + width: 100%; + min-height: 52px; + align-items: center; + gap: 12px; + padding: 10px 14px; + border: 1px solid var(--publish-status-border); + border-radius: 10px; + background: var(--publish-status-background); + color: #344054; + cursor: pointer; + text-align: left; + transition: border-color 0.2s, box-shadow 0.2s; +} +.editor-publish-status:hover { + border-color: var(--publish-status-accent); + box-shadow: 0 2px 8px rgba(16, 24, 40, 0.06); +} +.editor-publish-status:focus-visible { + outline: 2px solid var(--publish-status-accent); + outline-offset: 2px; +} +.editor-publish-status--progress { + --publish-status-border: #d9d6f6; + --publish-status-background: #f7f6ff; + --publish-status-accent: #7c52e8; +} +.editor-publish-status--success { + --publish-status-border: #cdebd5; + --publish-status-background: #f5fbf6; + --publish-status-accent: #389e55; +} +.editor-publish-status--warning { + --publish-status-border: #f5e2b9; + --publish-status-background: #fffaf0; + --publish-status-accent: #ad6800; +} +.editor-publish-status--error { + --publish-status-border: #f2cccc; + --publish-status-background: #fff7f7; + --publish-status-accent: #cf3d3d; +} +.editor-publish-status-icon { + display: grid; + width: 30px; + height: 30px; + flex: 0 0 auto; + place-items: center; + border-radius: 8px; + background: #fff; + color: var(--publish-status-accent); + font-size: 16px; +} +.editor-publish-status-content { + display: flex; + min-width: 0; + flex: 1; + align-items: baseline; + gap: 10px; +} +.editor-publish-status-title { + color: #252339; + font-weight: 600; +} +.editor-publish-status-count { + color: #73718a; + font-size: 13px; +} +.editor-publish-status-link { + flex: 0 0 auto; + color: var(--publish-status-accent); + font-size: 13px; + font-weight: 500; +} +.editor-publish-status-link .anticon { + margin-left: 3px; + font-size: 11px; +} /* 工作区:左侧主编辑列 + 右侧采集信息面板(见 CollectedProductInfoPanel.css) */ .editor-workspace { @@ -299,6 +381,10 @@ } } @media (max-width: 560px) { + .editor-publish-status-content { + flex-wrap: wrap; + gap: 0 8px; + } .product-editor { padding-bottom: 230px; } diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index a2312e6..a1396f6 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -27,16 +27,18 @@ import { apiErrorCode, apiErrorMessage } from '@/services/api'; import { productEditorApi } from '@/services/productEditor'; import { usePlatform } from '@/services/platform'; import { collectionApi } from '@/services/collection'; -import { isPositivePackageInteger } from '@/components/PackageIntegerInput'; +import { isPositivePackageInteger, normalizePackageInteger } from '@/components/PackageIntegerInput'; import { CollectedProductInfoPanel, type CollectedProductSummary, } from './components/CollectedProductInfoPanel'; import { ProductAttributeSection } from './components/ProductAttributeSection'; +import { ProductPublishActions } from './components/ProductPublishActions'; import { ProductMainInfoSection } from './components/ProductMainInfoSection'; import { ProductOtherSection } from './components/ProductOtherSection'; import { ProductVariantSection } from './components/ProductVariantSection'; import { ProductImageTaskCenter } from './components/ProductImageTaskCenter'; +import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay'; import { ProductCopyComparisonModal, SkuCopyAiModal, @@ -97,7 +99,7 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit common: { ...snapshot.product.common, default_physical_spec: snapshot.product.default_physical_spec, - default_package_spec: snapshot.product.default_package_spec, + default_package_spec: normalizedPackageSpec(snapshot.product.default_package_spec), package_weight_mode: snapshot.product.package_weight_mode, package_dimensions_mode: snapshot.product.package_dimensions_mode, }, @@ -107,7 +109,8 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit platformCode.toUpperCase() === 'OZON' && snapshot.profile.fulfillment === 'unknown' ? 'rfbs' : (snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment']), - title: snapshot.profile.title, + title: snapshot.profile.title_mode === 'variant' ? null : snapshot.profile.title, + title_mode: snapshot.profile.title_mode, description: snapshot.profile.description, tags: snapshot.profile.tags, platform_product_category_id: snapshot.profile.category.id, @@ -141,7 +144,7 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit tags: variant.tags, description: variant.description, physical_spec: variant.physical_spec, - package_spec: variant.package_spec, + package_spec: normalizedPackageSpec(variant.package_spec), offer_id: variant.listing.offer_id, media_asset_ids: variant.media.map((media) => media.asset_id), })), @@ -153,6 +156,55 @@ function nullable(value: string | null | undefined) { return trimmed || null; } +function normalizedPackageSpec< + T extends { + length_mm: string | null; + width_mm: string | null; + height_mm: string | null; + shipping_weight_g: string | null; + }, +>(spec: T): T { + const integerOrOriginal = (value: string | null) => + nullable(normalizePackageInteger(value?.trim())); + return { + ...spec, + length_mm: integerOrOriginal(spec.length_mm), + width_mm: integerOrOriginal(spec.width_mm), + height_mm: integerOrOriginal(spec.height_mm), + shipping_weight_g: integerOrOriginal(spec.shipping_weight_g), + }; +} + +/** 草稿可缺少包装规格,但已填写的重量和尺寸必须是正整数。 */ +function packageIntegerProblem(values: EditorFormValues): string | null { + const check = ( + spec: Pick< + EditorFormValues['common']['default_package_spec'], + 'shipping_weight_g' | 'length_mm' | 'width_mm' | 'height_mm' + >, + owner: string + ) => { + for (const [key, label] of [ + ['shipping_weight_g', '包装重量'], + ['length_mm', '包装长度'], + ['width_mm', '包装宽度'], + ['height_mm', '包装高度'], + ] as const) { + const value = spec[key]; + if (value !== null && value !== '' && !isPositivePackageInteger(value)) + return `${owner}的${label}请输入大于 0 的整数`; + } + return null; + }; + const commonProblem = check(values.common.default_package_spec, '商品'); + if (commonProblem) return commonProblem; + for (const [index, variant] of values.variants.entries()) { + const problem = check(variant.package_spec, variant.display_name || `SKU ${index + 1}`); + if (problem) return problem; + } + return null; +} + const wait = (milliseconds: number) => new Promise((resolve) => window.setTimeout(resolve, milliseconds)); @@ -229,23 +281,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues { height_mm: nullable(spec.height_mm), net_weight_g: nullable(spec.net_weight_g), }); - const packageSpec = < - T extends { - length_mm: string | null; - width_mm: string | null; - height_mm: string | null; - shipping_weight_g: string | null; - }, - >( - spec: T - ): T => ({ - ...spec, - length_mm: nullable(spec.length_mm), - width_mm: nullable(spec.width_mm), - height_mm: nullable(spec.height_mm), - shipping_weight_g: nullable(spec.shipping_weight_g), - }); - const commonPackage = packageSpec(values.common.default_package_spec); + const commonPackage = normalizedPackageSpec(values.common.default_package_spec); const weightByVariant = values.common.package_weight_mode === 'variant'; const dimensionsByVariant = values.common.package_dimensions_mode === 'variant'; const packageComplete = ( @@ -278,6 +314,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues { profile: { ...values.profile, title: nullable(values.profile.title), + title_mode: values.profile.title_mode ?? 'common', description: nullable(values.profile.description), tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean), }, @@ -288,7 +325,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues { return attributeValues.length ? [{ ...attribute, values: attributeValues }] : []; }), variants: values.variants.map((variant) => { - const normalizedPackage = packageSpec(variant.package_spec); + const normalizedPackage = normalizedPackageSpec(variant.package_spec); if (weightByVariant || dimensionsByVariant) { const effectivePackage = { ...normalizedPackage, @@ -359,7 +396,7 @@ function collectedSummary(facts: ProductAiSourceMaterial): CollectedProductSumma }; return { price: facts.price ? `${facts.price.amount} ${facts.price.currency_code}` : '—', - brand: facts.brand ?? find((label) => /brand|品牌/.test(label)), + brand: displayBrandName(facts.brand ?? find((label) => /brand|品牌/.test(label))), physicalDimensions: dimensions(dimension(false)), physicalWeight: grams(weight(false)), packageDimensions: dimensions(dimension(true)), @@ -458,6 +495,7 @@ const ProductEditPage = () => { const [saving, setSaving] = useState(false); const [savingSourceDescription, setSavingSourceDescription] = useState(false); const [dirty, setDirty] = useState(false); + const [publishStatusContainer, setPublishStatusContainer] = useState(null); const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false); const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false); const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false); @@ -497,10 +535,9 @@ const ProductEditPage = () => { : ([] as ProductAttributeSchema[]), [attributeSchema, attributeSchemaCategoryId, selectedCategoryId] ); - // 已由编辑器内建控件承载的 Ozon 属性不再重复展示或加载字典。 - const editableAttributeSchema = useMemo( - () => activeAttributeSchema.filter((attribute) => !attribute.editor_field), - [activeAttributeSchema] + // 内建品牌字段同样使用当前类目的 Ozon 字典,其余内建字段无需加载字典。 + const brandDefinition = activeAttributeSchema.find( + (attribute) => attribute.editor_field === 'brand' ); /** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */ @@ -519,7 +556,7 @@ const ProductEditPage = () => { setUsesVariantPackageDimensions(next.product.package_dimensions_mode === 'variant'); setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0)); setUsesVariantDescription(next.variants.some((variant) => !!variant.description)); - setUsesVariantTitle(next.variants.some((variant) => !!variant.title)); + setUsesVariantTitle(next.profile.title_mode === 'variant'); setCopyTranslations(next.copy_translations); setDirty(false); } catch (error) { @@ -637,13 +674,16 @@ const ProductEditPage = () => { selectedCategoryId, snapshot, ]); - // 预取当前 schema 涉及的所有字典值,供属性下拉框展示与远程搜索兜底。 + // 预取类目属性字典;品牌初始只查询 Ozon 的无品牌候选,其他品牌按关键词加载。 useEffect(() => { if (!snapshot || !currentPlatform || !selectedCategoryId) return; const attributeIds = [ ...new Set( - editableAttributeSchema - .filter((attribute) => !!attribute.dictionary_id) + activeAttributeSchema + .filter((attribute) => + !!attribute.dictionary_id && + (!attribute.editor_field || attribute.editor_field === 'brand') + ) .map((attribute) => attribute.attribute_id) ), ]; @@ -662,7 +702,9 @@ const ProductEditPage = () => { snapshot.profile.id, currentPlatform.id, attributeId, - undefined, + attributeId === brandDefinition?.attribute_id + ? OZON_NO_BRAND_VALUE + : undefined, selectedCategoryId ), ] as const @@ -698,7 +740,7 @@ const ProductEditPage = () => { return () => { active = false; }; - }, [catalogRefresh, currentPlatform, editableAttributeSchema, selectedCategoryId, snapshot]); + }, [catalogRefresh, currentPlatform, activeAttributeSchema, selectedCategoryId, snapshot]); useEffect(() => { if (!snapshot || !currentPlatform) return; let active = true; @@ -821,30 +863,63 @@ const ProductEditPage = () => { const useVariantPackageDimensions = () => changePackageMode('dimensions', 'variant'); const useCommonPackageWeight = () => confirmCommonPackageMode('weight'); const useCommonPackageDimensions = () => confirmCommonPackageMode('dimensions'); - /** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */ + /** SKU 标签和描述继续按覆盖值继承商品通用文案。 */ const useVariantTags = () => setUsesVariantTags(true); const useVariantDescription = () => setUsesVariantDescription(true); - const useVariantTitle = () => setUsesVariantTitle(true); + /** 俄文标题模式切换时清空退出模式的值,避免隐藏文案继续参与发布。 */ + const changeTitleMode = (mode: 'common' | 'variant') => { + if (mode === 'variant') { + form.setFieldValue(['profile', 'title'], null); + setCopyTranslations((current) => + current.filter((item) => item.field !== 'title' || item.variant_id !== null) + ); + } else { + const variants = (form.getFieldValue('variants') ?? []) as EditorFormValues['variants']; + variants.forEach((_, index) => form.setFieldValue(['variants', index, 'title'], null)); + setCopyTranslations((current) => + current.filter((item) => item.field !== 'title' || item.variant_id === null) + ); + } + form.setFieldValue(['profile', 'title_mode'], mode); + setUsesVariantTitle(mode === 'variant'); + setDirty(true); + }; + const useVariantTitle = () => changeTitleMode('variant'); + const useCommonTitle = () => + modal.confirm({ + title: '将俄文标题设为通用?', + content: '确认后将清空所有 SKU 的俄文标题,并恢复商品通用俄文标题的编辑。', + okText: '确认调整', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: () => changeTitleMode('common'), + }); /** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */ const save = async (values: EditorFormValues) => { values = { ...values, ...form.getFieldsValue(true) }; if (!productId || !currentPlatform || !snapshot) return false; + const draft = normalizedValues({ + ...values, + ai_source_material: sourceDraft ?? snapshot.source_material, + ai_source_overridden_fields: sourceOverriddenFields, + copy_translations: copyTranslations.filter( + (item) => + !item.variant_id || + values.variants.some((variant) => variant.id === item.variant_id) + ), + }); + const invalidPackage = packageIntegerProblem(draft); + if (invalidPackage) { + message.error(invalidPackage); + return false; + } setSaving(true); try { await productEditorApi.save( productId, currentPlatform.id, { product: snapshot.product.revision, profile: snapshot.profile.revision }, - normalizedValues({ - ...values, - ai_source_material: sourceDraft ?? snapshot.source_material, - ai_source_overridden_fields: sourceOverriddenFields, - copy_translations: copyTranslations.filter( - (item) => - !item.variant_id || - values.variants.some((variant) => variant.id === item.variant_id) - ), - }) + draft ); message.success('草稿已保存'); setDirty(false); @@ -996,7 +1071,7 @@ const ProductEditPage = () => { variants[index] = { ...variants[index], offer_id: offer.offer_id }; } const copy = result.spu_copy; - if (copy?.title && !profile.title) { + if (copy?.title && profile.title_mode !== 'variant' && !profile.title) { profile.title = copy.title.ru; addCopyTranslations(null, { title: copy.title }, taskId); } @@ -1082,6 +1157,7 @@ const ProductEditPage = () => { const index = variants.findIndex((variant) => variant.id === skuCopyVariantId); if (index < 0) return; const copy = skuCopyResult; + if (copy.title && !usesVariantTitle) changeTitleMode('variant'); form.setFieldValue( 'variants', variants.map((variant, current) => @@ -1098,7 +1174,6 @@ const ProductEditPage = () => { // SKU 生成结果只在用户明确应用后写入;中文对照与俄文同时保存。 if (skuCopyTaskId.current) addCopyTranslations(skuCopyVariantId, copy, skuCopyTaskId.current); - setUsesVariantTitle(true); setUsesVariantTags(true); setUsesVariantDescription(true); setDirty(true); @@ -1406,6 +1481,7 @@ const ProductEditPage = () => { +
{snapshot.warnings.map((warning) => ( @@ -1436,11 +1512,21 @@ const ProductEditPage = () => { usesVariantPackageDimensions={usesVariantPackageDimensions} usesVariantTitle={usesVariantTitle} onUseVariantTitle={useVariantTitle} + onUseCommonTitle={useCommonTitle} onCompareTitle={() => setComparison({ variantId: null, field: 'title' })} onUseVariantPackageWeight={useVariantPackageWeight} onUseVariantPackageDimensions={useVariantPackageDimensions} onUseCommonPackageWeight={useCommonPackageWeight} onUseCommonPackageDimensions={useCommonPackageDimensions} + brandDefinition={brandDefinition} + brandOptions={brandDefinition ? dictionaryValues[brandDefinition.attribute_id] ?? [] : []} + onOpenBrandOptions={(attributeId) => + void loadDictionaryValues(attributeId, OZON_NO_BRAND_VALUE) + } + onSearchBrandOptions={(attributeId, query) => + void loadDictionaryValues(attributeId, query) + } + onLoadMoreBrandOptions={loadMoreDictionaryValues} /> { usesVariantTags={usesVariantTags} usesVariantDescription={usesVariantDescription} usesVariantTitle={usesVariantTitle} + onUseCommonTitle={useCommonTitle} onAiCopy={(variantId) => { setSkuCopyVariantId(variantId); setSkuCopyResult(null); @@ -1495,6 +1582,7 @@ const ProductEditPage = () => { skuCopyTaskId.current = null; }} onCompareCopy={(variantId, field) => setComparison({ variantId, field })} + onAttributeChanged={() => setDirty(true)} onImagesChanged={() => setDirty(true)} /> @@ -1539,13 +1627,27 @@ const ProductEditPage = () => { - + { + if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return null; + return productEditorApi.read(snapshot.product.id, currentPlatform.id); + }} + onStatusChange={async () => { + const next = await productEditorApi.read(snapshot.product.id, currentPlatform.id); + setSnapshot(current => current ? {...current, profile: {...current.profile, listing_summary: next.profile.listing_summary}, variants: current.variants.map(v => ({...v, listing: next.variants.find(n => n.id === v.id)?.listing ?? v.listing}))} : current); + }} + /> [number]; -type NamePath = Array; const productColorValueLimit = 100; /** @@ -118,6 +117,7 @@ type Props = { itemLayout?: 'horizontal' | 'vertical'; forceRequiredNames?: string[]; colorNameDefinition?: ProductAttributeSchema; + onAttributeChanged?: () => void; }; /** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */ @@ -152,6 +152,7 @@ function DynamicAttributeField({ scope, variantId, colorNameDefinition, + onAttributeChanged, }: { definition: ProductAttributeSchema; index: number; @@ -167,6 +168,7 @@ function DynamicAttributeField({ scope: Props['scope']; variantId: Props['variantId']; colorNameDefinition: Props['colorNameDefinition']; + onAttributeChanged: Props['onAttributeChanged']; }) { const [dictionaryQuery, setDictionaryQuery] = useState(''); const onSyncRef = useRef(onSyncDictionaryValues); @@ -181,12 +183,25 @@ function DynamicAttributeField({ return () => window.clearTimeout(timer); }, [definition.attribute_id, definition.dictionary_id, dictionaryQuery]); - const valueName = ['attributes', index, 'values'] as NamePath; - const scalarValueName = [...valueName, 0] as NamePath; - const selectedProductColorValue = Form.useWatch( - ['attributes', index, 'values'], - form - ) as unknown; + const valueName: ['attributes', number, 'values'] = ['attributes', index, 'values']; + const scalarValueName: ['attributes', number, 'values', 0] = [ + 'attributes', + index, + 'values', + 0, + ]; + // SKU 卡片位于 Form.List('variants') 内,命名 Form.Item 会自动附加 variants 前缀。 + const directVariantBinding = scope === 'variant'; + const watchedValues = Form.useWatch(valueName, { form, preserve: true }) as unknown; + const attributeValues = Array.isArray(watchedValues) + ? watchedValues.filter((value): value is string => typeof value === 'string') + : typeof watchedValues === 'string' + ? [watchedValues] + : []; + const setVariantValues = (values: string[]) => { + form.setFieldValue(valueName, values); + onAttributeChanged?.(); + }; const lastColorSelection = useRef(null); const allowsMultipleValues = definition.is_collection || isProductColor(definition); const isRequired = @@ -204,11 +219,7 @@ function DynamicAttributeField({ ), })) : []; - const selectedColorValueIds = Array.isArray(selectedProductColorValue) - ? selectedProductColorValue.filter((value): value is string => typeof value === 'string') - : typeof selectedProductColorValue === 'string' - ? [selectedProductColorValue] - : []; + const selectedColorValueIds = attributeValues; const colorSelectionKey = selectedColorValueIds.join('\u0000'); /** 商品颜色变更时,将俄文表达立即写入同一 SKU 的颜色文字字段。 */ const syncColorName = (selectedValue: unknown) => { @@ -254,16 +265,32 @@ function DynamicAttributeField({ const input = definition.value_type === 'dictionary' ? ( - + setVariantValues(value ? [value] : []) + : undefined + } options={[ { value: 'true', label: '是' }, { value: 'false', label: '否' }, @@ -323,25 +356,42 @@ function DynamicAttributeField({ /> ) : allowsMultipleValues ? ( - + + + + setVariantValues(event.target.value ? [event.target.value] : []) + : undefined + } + placeholder={`填写${definition.name}`} + /> ); @@ -384,6 +434,7 @@ export function DynamicAttributeFields({ itemLayout, forceRequiredNames, colorNameDefinition, + onAttributeChanged, }: Props) { const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[]; const fields = definitions.flatMap((definition) => { @@ -413,6 +464,7 @@ export function DynamicAttributeFields({ scope={scope} variantId={variantId} colorNameDefinition={colorNameDefinition} + onAttributeChanged={onAttributeChanged} /> ))}
diff --git a/apps/web/src/pages/products/components/ProductMainInfoSection.tsx b/apps/web/src/pages/products/components/ProductMainInfoSection.tsx index 3eee7af..afcfedd 100644 --- a/apps/web/src/pages/products/components/ProductMainInfoSection.tsx +++ b/apps/web/src/pages/products/components/ProductMainInfoSection.tsx @@ -1,8 +1,14 @@ +import { useEffect, useRef, useState } from 'react'; import { Button, Card, Cascader, Form, Input, Radio, Select, Tooltip } from 'antd'; import type { FormInstance } from 'antd'; -import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons'; -import type { ProductEditorSnapshot } from '@atlas/contracts'; +import { TranslationOutlined } from '@ant-design/icons'; +import type { + ProductAttributeDictionaryValue, + ProductAttributeSchema, + ProductEditorSnapshot, +} from '@atlas/contracts'; import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput'; +import { displayBrandName, OZON_NO_BRAND_VALUE } from '../brandDisplay'; import { SectionTitle } from './SectionTitle'; import './ProductMainInfoSection.css'; @@ -110,7 +116,13 @@ type Props = { onUseCommonPackageDimensions: () => void; usesVariantTitle: boolean; onUseVariantTitle: () => void; + onUseCommonTitle: () => void; onCompareTitle: () => void; + brandDefinition: ProductAttributeSchema | undefined; + brandOptions: ProductAttributeDictionaryValue[]; + onOpenBrandOptions: (attributeId: string) => void; + onSearchBrandOptions: (attributeId: string, query: string) => void; + onLoadMoreBrandOptions: (attributeId: string, query: string) => void; }; /** @@ -136,8 +148,36 @@ export function ProductMainInfoSection({ onUseCommonPackageDimensions, usesVariantTitle, onUseVariantTitle, + onUseCommonTitle, onCompareTitle, + brandDefinition, + brandOptions, + onOpenBrandOptions, + onSearchBrandOptions, + onLoadMoreBrandOptions, }: Props) { + const publishingLocked = snapshot.variants.some(v => ['imported', 'submitting'].includes(v.listing.status)); + const [brandQuery, setBrandQuery] = useState(''); + const onSearchBrandOptionsRef = useRef(onSearchBrandOptions); + onSearchBrandOptionsRef.current = onSearchBrandOptions; + // 中文“无品牌”仅用于展示与搜索,表单始终保存用户选中的 Ozon 候选名称。 + const brandSearchQuery = + brandQuery.trim() === '无品牌' ? OZON_NO_BRAND_VALUE : brandQuery.trim(); + const visibleBrandOptions = brandOptions.filter((option) => + brandSearchQuery + ? option.value.toLocaleLowerCase().includes(brandSearchQuery.toLocaleLowerCase()) + : option.value === OZON_NO_BRAND_VALUE + ); + useEffect(() => { + if (!brandDefinition?.dictionary_id || !brandSearchQuery) return; + const timer = window.setTimeout( + () => onSearchBrandOptionsRef.current(brandDefinition.attribute_id, brandSearchQuery), + 400 + ); + return () => window.clearTimeout(timer); + }, [brandDefinition?.attribute_id, brandDefinition?.dictionary_id, brandSearchQuery]); + const usesBrandDictionary = + brandDefinition?.value_type === 'dictionary' && !!brandDefinition.dictionary_id; return ( - +
- + - {!usesVariantTitle && ( - -
Promise; + onStatusChange: () => Promise; +}; +const terminal = (job: ProductPublishJob) => + ['succeeded', 'partially_succeeded', 'failed'].includes(job.status); +const itemLabels: Record = { + queued: '排队中', + submitting: '提交中', + polling: '等待 Ozon', + imported: '已导入', + failed: '失败', + skipped: '已跳过', + reconciling: '等待对账', +}; +const jobLabels: Record = { + queued: '排队中', + running: '处理中', + succeeded: '已导入', + partially_succeeded: '部分已导入', + failed: '未导入', + reconciling: '等待对账', +}; + +/** 两个发布入口共用保存、指定 SKU 预检、确认与可恢复任务状态。 */ +export function ProductPublishActions({ + snapshot, + platformId, + disabled, + statusContainer, + ensureSaved, + onStatusChange, +}: Props) { + const { message } = App.useApp(); + const [open, setOpen] = useState(false); + const [stage, setStage] = useState<'select' | 'check' | 'confirm' | 'result'>('select'); + const [saved, setSaved] = useState(snapshot); + const [ids, setIds] = useState([]); + const [busy, setBusy] = useState(false); + const [preflight, setPreflight] = useState(null); + const [error, setError] = useState(''); + const [job, setJob] = useState(null); + const requestKey = useRef(null); + const run = useRef(0); + const statusChanged = useRef(onStatusChange); + statusChanged.current = onStatusChange; + useEffect(() => { + let cancelled = false; + setJob(null); + productEditorApi + .publishJobs(snapshot.profile.id, platformId) + .then((jobs) => { + if (!cancelled) setJob(jobs.find((j) => !terminal(j)) ?? jobs[0] ?? null); + }) + .catch(() => {}); + return () => { + cancelled = true; + run.current++; + }; + }, [snapshot.profile.id, platformId]); + useEffect(() => { + if (!job || terminal(job)) return; + let cancelled = false; + let timer: ReturnType; + const poll = async () => { + try { + const next = await productEditorApi.publishJob(job.id, platformId); + if (cancelled) return; + setJob(next); + await statusChanged.current(); + if (terminal(next)) return; + } catch (e) { + if (!cancelled) setError(apiErrorMessage(e)); + } + if (!cancelled) timer = setTimeout(() => void poll(), 5000); + }; + timer = setTimeout(() => void poll(), 2000); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [job?.id, job?.status, platformId]); + const eligible = (data: ProductEditorSnapshot) => + data.variants.filter((v) => ['not_listed', 'failed'].includes(v.listing.status)); + const skipReason = (status: string) => + status === 'imported' + ? '已有成功导入记录,不能重复提交' + : '正在提交或等待对账,不能重复排队'; + const check = async (data: ProductEditorSnapshot, selected: string[]) => { + if (!selected.length) { + message.warning('请至少选择 1 个待上架 SKU'); + return; + } + if (selected.length > 50) { + message.warning('本次最多上架 50 个 SKU,请使用部分上架分批提交'); + return; + } + const thisRun = ++run.current; + setStage('check'); + setBusy(true); + setError(''); + setPreflight(null); + requestKey.current = null; + try { + for (let attempt = 0; attempt < 6; attempt++) { + const result = await productEditorApi.publishPreflight( + data.profile.id, + platformId, + selected + ); + if (thisRun !== run.current) return; + setPreflight(result); + if (result.status !== 'refreshing') { + setStage(result.status === 'ready' ? 'confirm' : 'check'); + return; + } + if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } catch (e) { + if (thisRun === run.current) setError(apiErrorMessage(e)); + } finally { + if (thisRun === run.current) setBusy(false); + } + }; + const start = async (partial: boolean) => { + setBusy(true); + setError(''); + try { + const data = await ensureSaved(); + if (!data) return; + setSaved(data); + setPreflight(null); + requestKey.current = null; + const all = eligible(data).map((v) => v.id); + setIds(partial ? [] : all); + setOpen(true); + setStage('select'); + if (!partial) { + if (all.length > 50) { + setError('待上架 SKU 超过 50 个,请选择本次需要上架的 SKU,分批提交。'); + } else if (!all.length) { + setError('没有可首次上架的 SKU。已导入的 SKU 请前往在线商品查看。'); + } else await check(data, all); + } + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setBusy(false); + } + }; + const submit = async () => { + setBusy(true); + setError(''); + requestKey.current ??= crypto.randomUUID(); + try { + const result = await productEditorApi.createPublishJob( + saved.profile.id, + platformId, + ids, + { product: saved.product.revision, profile: saved.profile.revision }, + requestKey.current + ); + setJob(result.publish_job); + setStage('result'); + await onStatusChange(); + } catch (e) { + setError(apiErrorMessage(e)); + } finally { + setBusy(false); + } + }; + const chosen = saved.variants.filter((v) => ids.includes(v.id)); + const issues = preflight + ? [ + ...preflight.validation.profile.issues.map((i) => ({ ...i, scope: '商品资料' })), + ...preflight.validation.variants.flatMap((v) => + v.issues.map((i) => ({ + ...i, + scope: + v.display_name || + saved.variants.find((s) => s.id === v.variant_id)?.listing.offer_id || + 'SKU', + })) + ), + ] + : []; + const statusTone = job?.status === 'succeeded' + ? 'success' + : job?.status === 'failed' + ? 'error' + : job?.status === 'partially_succeeded' || job?.status === 'reconciling' + ? 'warning' + : 'progress'; + const statusIcon = statusTone === 'success' + ? + : statusTone === 'progress' + ? + : ; + return ( + <> + {/* 发布状态与结果弹窗共用任务数据,状态入口展示在页头。 */} + {job && statusContainer && createPortal( + , + statusContainer + )} + + + + + { + run.current++; + setOpen(false); + setBusy(false); + }} + footer={ + stage === 'result' ? ( + + ) : ( + + + {stage === 'confirm' ? ( + + ) : ( + + )} + + ) + } + > + {error && ( + + )} + {stage === 'result' && job ? ( + <> + + i.display_name || '未命名 SKU' }, + { title: '货号', dataIndex: 'offer_id' }, + { + title: '状态', + render: (_, i) => ( + + {itemLabels[i.status]} + + ), + }, + { + title: '结果说明', + render: (_, i) => + i.errors.map((e) => ( +
{e.message}
+ )), + }, + ]} + /> + + ) : ( + <> + + 目标店铺:{saved.profile.target_store.name}。本次最多 50 个 + SKU,仅首次导入商品资料;库存需在 Ozon 后台设置。 + + {saved.variants.some( + (v) => !['not_listed', 'failed'].includes(v.listing.status) + ) && ( + !['not_listed', 'failed'].includes(v.listing.status) + ) + .map((v) => ( +
+ {v.display_name || v.listing.offer_id || '未命名 SKU'}: + {skipReason(v.listing.status)} +
+ ))} + style={{ marginBottom: 16 }} + /> + )} + {stage === 'select' ? ( +
{ + setIds(keys as string[]); + setPreflight(null); + }, + getCheckboxProps: (v) => ({ + disabled: !['not_listed', 'failed'].includes( + v.listing.status + ), + }), + }} + columns={[ + { title: 'SKU', dataIndex: 'display_name' }, + { + title: '货号', + render: (_, v) => v.listing.offer_id || '未填写', + }, + { + title: '状态', + render: (_, v) => + v.listing.status === 'imported' + ? '已导入' + : v.listing.status === 'submitting' + ? '提交中' + : v.listing.status === 'failed' + ? '可修正后重试' + : '待首次上架', + }, + ]} + /> + ) : ( +
v.listing.offer_id }, + { + title: '售价', + render: (_, v) => + `${v.listing.selling_price} ${saved.profile.target_store.currency_code}`, + }, + { title: '图片', render: (_, v) => `${v.media.length} 张` }, + ]} + /> + )} + {preflight?.status === 'refreshing' && ( + + )} + {!!issues.length && ( + ( +
+ {i.scope}:{i.message} +
+ ))} + style={{ marginTop: 16 }} + /> + )} + {stage === 'confirm' && ( + + )} + + )} + + + ); +} diff --git a/apps/web/src/pages/products/components/ProductVariantSection.css b/apps/web/src/pages/products/components/ProductVariantSection.css index 8ce0cc0..2deb278 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.css +++ b/apps/web/src/pages/products/components/ProductVariantSection.css @@ -62,6 +62,16 @@ color: #90889c; font-size: 11px; text-align: center; + padding: 0; + cursor: pointer; + font-family: inherit; +} +.sku-main-thumbnail:hover { + border-color: #8956f5; +} +.sku-main-thumbnail:focus-visible { + outline: 2px solid #8956f5; + outline-offset: 2px; } .sku-main-thumbnail img { width: 100%; @@ -212,6 +222,9 @@ color: #292433; white-space: nowrap; } +.sku-expand-button .anticon-down { + font-size: 12px; +} .sku-card-expanded { grid-column: 1 / -1; grid-row: 3; @@ -393,6 +406,9 @@ .sku-info-panel .sku-info-package-row .ant-form-item-label > label { width: 100%; } +.sku-info-panel .sku-info-variant-title .ant-form-item-label > label { + width: 100%; +} .sku-package-label { display: inline-flex; flex: 1; diff --git a/apps/web/src/pages/products/components/ProductVariantSection.tsx b/apps/web/src/pages/products/components/ProductVariantSection.tsx index d81204d..90637c9 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.tsx +++ b/apps/web/src/pages/products/components/ProductVariantSection.tsx @@ -101,8 +101,10 @@ type Props = { usesVariantTags: boolean; usesVariantDescription: boolean; usesVariantTitle: boolean; + onUseCommonTitle: () => void; onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void; onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void; + onAttributeChanged: () => void; onImagesChanged: () => void; }; @@ -130,8 +132,10 @@ export function ProductVariantSection({ usesVariantTags, usesVariantDescription, usesVariantTitle, + onUseCommonTitle, onAiCopy, onCompareCopy, + onAttributeChanged, onImagesChanged, }: Props) { const [pricingAuto, setPricingAuto] = useState( @@ -432,6 +436,7 @@ export function ProductVariantSection({ imageUrl={displayImages[0]?.url} imageCount={selectedAssetIds.length} sourcePreview={!!sourceMainCandidate} + onPick={pickImages} />
@@ -541,6 +546,7 @@ export function ProductVariantSection({ usesVariantDescription } usesVariantTitle={usesVariantTitle} + onUseCommonTitle={onUseCommonTitle} skuInfoAspectDefinitions={ skuInfoAspectDefinitions } @@ -561,6 +567,7 @@ export function ProductVariantSection({ onLoadMoreDictionaryValues={ onLoadMoreDictionaryValues } + onAttributeChanged={onAttributeChanged} onAiCopy={onAiCopy} onCompareCopy={onCompareCopy} /> diff --git a/apps/web/src/pages/products/components/SkuCardPreview.tsx b/apps/web/src/pages/products/components/SkuCardPreview.tsx index 973b1a7..7f5485a 100644 --- a/apps/web/src/pages/products/components/SkuCardPreview.tsx +++ b/apps/web/src/pages/products/components/SkuCardPreview.tsx @@ -4,18 +4,21 @@ type Props = { imageUrl?: string; imageCount: number; sourcePreview: boolean; + onPick: () => void; }; /** 收起态主图沿用 SKU 图片顺序,来源候选图不计入已选图片数量。 */ -export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) { +export function SkuCardPreview({ imageUrl, imageCount, sourcePreview, onPick }: Props) { const imageLabel = sourcePreview ? '来源图' : '主图'; return (
-
{imageUrl ? ( <> @@ -28,7 +31,7 @@ export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) { -
+
); } diff --git a/apps/web/src/pages/products/components/SkuInfoPanel.tsx b/apps/web/src/pages/products/components/SkuInfoPanel.tsx index 07ef031..6ea3769 100644 --- a/apps/web/src/pages/products/components/SkuInfoPanel.tsx +++ b/apps/web/src/pages/products/components/SkuInfoPanel.tsx @@ -22,6 +22,7 @@ type Props = { usesVariantTags: boolean; usesVariantDescription: boolean; usesVariantTitle: boolean; + onUseCommonTitle: () => void; skuInfoAspectDefinitions: ProductAttributeSchema[]; colorNameDefinition: ProductAttributeSchema | undefined; catalogSyncing: string | null; @@ -30,6 +31,7 @@ type Props = { onSyncDictionaryValues: (dictionaryId: string, query: string) => void; onOpenDictionaryValues: (dictionaryId: string) => void; onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void; + onAttributeChanged: () => void; onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void; onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void; }; @@ -66,6 +68,7 @@ export function SkuInfoPanel({ usesVariantTags, usesVariantDescription, usesVariantTitle, + onUseCommonTitle, skuInfoAspectDefinitions, colorNameDefinition, catalogSyncing, @@ -74,6 +77,7 @@ export function SkuInfoPanel({ onSyncDictionaryValues, onOpenDictionaryValues, onLoadMoreDictionaryValues, + onAttributeChanged, onAiCopy, onCompareCopy, }: Props) { @@ -86,7 +90,7 @@ export function SkuInfoPanel({ - + {(usesVariantPackageWeight || usesVariantPackageDimensions) && ( @@ -144,30 +148,39 @@ export function SkuInfoPanel({ )}
发布文案与类目属性
- - - - - - + + } + layout="vertical" + className="sku-info-variant-title" + required + > + + + + +