From 946af649dd48d0057a9663759616d1862333091d Mon Sep 17 00:00:00 2001 From: R524809 Date: Thu, 10 Sep 2026 17:45:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(edit):=20=E5=95=86=E5=93=81=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E9=A1=B5=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/extension/extension.service.ts | 169 +- .../product-editor.controller.ts | 195 +- .../product-editor/product-editor.service.ts | 2000 +++++++++++++++-- apps/api/test/extension.e2e.test.ts | 110 +- apps/api/test/product-editor.e2e.test.ts | 1088 ++++++++- apps/web/src/App.tsx | 11 +- .../src/pages/products/ProductEditPage.css | 758 +++++++ .../src/pages/products/ProductEditPage.tsx | 1870 ++++++++++++++- apps/web/src/services/productEditor.ts | 109 +- apps/worker/src/main.ts | 35 +- apps/worker/src/ozon-catalog-sync.ts | 499 ++++ apps/worker/src/ozon-client.ts | 107 +- docs/api/README.md | 2 +- docs/api/collection-and-extension.md | 4 +- docs/api/product-editor-and-publishing.md | 66 +- docs/product/product-ai-editor.md | 6 +- docs/project/HANDOFF.md | 71 +- .../collector-browser-extension.md | 4 +- packages/contracts/src/product-editor.ts | 352 ++- .../019_product_editor_ai_source_material.sql | 10 + .../020_product_ai_source_overrides.sql | 40 + .../021_product_category_attribute_schema.sql | 46 + 22 files changed, 7034 insertions(+), 518 deletions(-) create mode 100644 apps/web/src/pages/products/ProductEditPage.css create mode 100644 apps/worker/src/ozon-catalog-sync.ts create mode 100644 packages/database/migrations/019_product_editor_ai_source_material.sql create mode 100644 packages/database/migrations/020_product_ai_source_overrides.sql create mode 100644 packages/database/migrations/021_product_category_attribute_schema.sql diff --git a/apps/api/src/extension/extension.service.ts b/apps/api/src/extension/extension.service.ts index 3c892bc..4df45cc 100644 --- a/apps/api/src/extension/extension.service.ts +++ b/apps/api/src/extension/extension.service.ts @@ -98,6 +98,7 @@ export class ExtensionService { product.rows[0].id, 1, payload, + false, ); return { status: 201, @@ -191,6 +192,19 @@ export class ExtensionService { payload: CollectedProductPayload, ) { const { status, sourcePrice } = sourceState(payload); + // Once a platform profile exists, product_variants are no longer a + // disposable collection projection: they may carry user edits and are + // referenced by platform listings. A re-collection refreshes the + // source evidence/candidates, but must not overwrite that draft or + // sever a SKU's publishing history. + const profileCount = await tx.query<{ count: string }>( + `SELECT count(*)::text AS count + FROM atlas.product_platform_profiles + WHERE product_id = $1`, + [productId], + ); + const preserveEditorDraft = + Number(profileCount.rows[0]?.count ?? 0) > 0; // 不删除旧候选/任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录, // 新快照在查询和 Worker 中均按 source_snapshot_revision 隔离。 await tx.query( @@ -222,25 +236,14 @@ export class ExtensionService { const snapshotRevision = Number(item.rows[0]?.source_snapshot_revision); if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1) throw new Error('采集来源快照版本更新失败'); - await tx.query( - `UPDATE atlas.products SET source_title = $1, updated_at = now(), revision = revision + 1 WHERE id = $2`, - [payload.common.title?.value ?? null, productId], - ); - // The historical task rows must remain auditable after replacing the - // source snapshot. Clear only their optional variant pointer before - // deleting old variants. The original composite FK used ON DELETE SET - // NULL and PostgreSQL tried to null both key columns, including the - // required workspace_id. - await tx.query( - `UPDATE atlas.collection_asset_transfers - SET source_variant_id = NULL, updated_at = now() - WHERE product_id = $1 AND source_variant_id IS NOT NULL`, - [productId], - ); - await tx.query( - 'DELETE FROM atlas.product_variants WHERE product_id = $1', - [productId], - ); + if (!preserveEditorDraft) { + await tx.query( + `UPDATE atlas.products + SET source_title = $1, updated_at = now(), revision = revision + 1 + WHERE id = $2`, + [payload.common.title?.value ?? null, productId], + ); + } await this.writeSourceSnapshotRows( tx, workspaceId, @@ -248,6 +251,7 @@ export class ExtensionService { productId, snapshotRevision, payload, + preserveEditorDraft, ); return snapshotRevision; } @@ -259,6 +263,7 @@ export class ExtensionService { productId: string, snapshotRevision: number, payload: CollectedProductPayload, + preserveEditorDraft: boolean, ) { const verifiedVariants = payload.variants.filter( (variant) => variant.sourceVariantKey.trim().length > 0, @@ -272,23 +277,99 @@ export class ExtensionService { displayName: '默认 SKU', }, ]; + const sourceKeys = variantRows + .map((variant) => variant.sourceVariantKey) + .filter((key): key is string => !!key); + const existing = await tx.query<{ + id: string; + source_variant_key: string | null; + deleted_at: Date | string | null; + }>( + `SELECT id, source_variant_key, deleted_at + FROM atlas.product_variants + WHERE product_id = $1 + AND (source_variant_key = ANY($2::text[]) OR source_variant_key IS NULL) + ORDER BY sort_order, id`, + [productId, sourceKeys], + ); + const existingBySourceKey = new Map( + existing.rows + .filter((variant) => variant.source_variant_key) + .map((variant) => [variant.source_variant_key!, variant]), + ); + // A capture without a stable source key has exactly one synthetic + // default SKU. Reuse an existing unkeyed row when possible; this keeps + // its platform mapping rather than attempting a duplicate insert. + const existingDefault = existing.rows.find( + (variant) => variant.source_variant_key === null, + ); const variants = new Map(); + const retainedVariantIds: string[] = []; for (let index = 0; index < variantRows.length; index += 1) { const variant = variantRows[index]; - const inserted = await tx.query<{ id: string }>( - `INSERT INTO atlas.product_variants (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order) - VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - [ - workspaceId, + const matched = variant.sourceVariantKey + ? existingBySourceKey.get(variant.sourceVariantKey) + : existingDefault; + let variantId: string; + if (matched) { + variantId = matched.id; + // A user may have deliberately removed an Atlas SKU after + // editing began. Keep that decision; its source evidence is + // still present in normalized_payload, but it is not silently + // revived into the editable/publishable SKU set. + if (!preserveEditorDraft) { + await tx.query( + `UPDATE atlas.product_variants + SET source_sku = $1, display_name = $2, sort_order = $3, + deleted_at = NULL, updated_at = now() + WHERE id = $4`, + [ + variant.sourceSku ?? null, + variant.displayName, + index, + variantId, + ], + ); + } + } else { + const inserted = await tx.query<{ id: string }>( + `INSERT INTO atlas.product_variants + (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + [ + workspaceId, + productId, + variant.sourceVariantKey, + variant.sourceSku ?? null, + variant.displayName, + index, + ], + ); + variantId = inserted.rows[0].id; + await this.ensureProfileListingsForVariant( + tx, productId, - variant.sourceVariantKey, - variant.sourceSku ?? null, - variant.displayName, - index, - ], - ); + variantId, + ); + } + retainedVariantIds.push(variantId); if (variant.sourceVariantKey) - variants.set(variant.sourceVariantKey, inserted.rows[0].id); + variants.set(variant.sourceVariantKey, variantId); + } + // Before an editor profile exists, variants are still just the latest + // source projection, so a missing source SKU can be safely hidden. Do + // not physically delete it: the partial source-key unique index does + // not exclude soft-deleted rows, and a later refresh may legitimately + // rediscover the same stable key. After editing begins, omission from a + // source refresh must never act as a user-requested SKU deletion. + if (!preserveEditorDraft) { + await tx.query( + `UPDATE atlas.product_variants + SET deleted_at = now(), updated_at = now() + WHERE product_id = $1 AND deleted_at IS NULL + AND NOT (id = ANY($2::uuid[]))`, + [productId, retainedVariantIds], + ); } for (const asset of payload.assets) { await tx.query( @@ -323,6 +404,28 @@ export class ExtensionService { ); } + private async ensureProfileListingsForVariant( + tx: { query: Database['query'] }, + productId: string, + variantId: string, + ) { + // A source refresh may discover a new real SKU after a user has + // already entered one or more platform editors. Every existing profile + // needs a fresh, explicitly not-listed mapping for that SKU; imported + // mappings for other SKU rows are untouched. + await tx.query( + `INSERT INTO atlas.product_variant_platform_listings + (workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, currency_code) + SELECT current_setting('app.workspace_id')::uuid, $1, p.id, p.platform_id, + $2, p.target_store_id, s.currency_code + FROM atlas.product_platform_profiles p + JOIN atlas.stores s ON s.id = p.target_store_id + WHERE p.product_id = $1 + ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`, + [productId, variantId], + ); + } + private async materialResponse( tx: { query: Database['query'] }, collectionItemId: string, @@ -360,7 +463,9 @@ export class ExtensionService { editor_entry_path: `/collection-test/${collectionItemId}`, materialization: { total: Number(count.rows[0]?.total ?? 0), - pending_selection: Number(count.rows[0]?.pending_selection ?? 0), + pending_selection: Number( + count.rows[0]?.pending_selection ?? 0, + ), queued: Number(count.rows[0]?.queued ?? 0), succeeded: Number(count.rows[0]?.succeeded ?? 0), failed: Number(count.rows[0]?.failed ?? 0), diff --git a/apps/api/src/product-editor/product-editor.controller.ts b/apps/api/src/product-editor/product-editor.controller.ts index 37ffb3a..c79bc45 100644 --- a/apps/api/src/product-editor/product-editor.controller.ts +++ b/apps/api/src/product-editor/product-editor.controller.ts @@ -1,6 +1,21 @@ -import { Body, Controller, Get, Headers, Inject, Param, Post, Put, Req, Res } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Headers, + Inject, + Param, + Post, + Put, + Query, + Req, + Res, +} from '@nestjs/common'; import { z } from 'zod'; -import { saveProductEditorDraftRequestSchema, type Workspace } from '@atlas/contracts'; +import { + saveProductEditorDraftRequestSchema, + type Workspace, +} from '@atlas/contracts'; import { ApiError } from '../common/api-error.js'; import type { AuthenticatedRequest } from '../auth/auth.guard.js'; import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; @@ -50,6 +65,137 @@ export class ProductEditorController { }; } + @Get('product-platform-profiles/:profileId/category-options') + async categoryOptions( + @Param('profileId') profileId: string, + @Query('q') query: string | undefined, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @CurrentWorkspace() workspace: Workspace, + ) { + return { + data: await this.editor.categoryOptions( + workspace.id, + requiredUuid('profile_id', profileId), + requiredUuid('X-Atlas-Platform-Id', platformId), + query, + ), + }; + } + + @Post('product-platform-profiles/:profileId/category-syncs') + async categorySync( + @Param('profileId') profileId: string, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @Headers('idempotency-key') key: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + @Res({ passthrough: true }) response: Response, + ) { + const result = await this.editor.requestCategoryTreeSync( + workspace.id, + request.auth.userId, + requiredUuid('profile_id', profileId), + requiredUuid('X-Atlas-Platform-Id', platformId), + requiredUuid('Idempotency-Key', key), + ); + response.status(result.status); + return result.body; + } + + @Get('product-platform-profiles/:profileId/attribute-schema') + async attributeSchema( + @Param('profileId') profileId: string, + @Query('category_id') categoryId: string | undefined, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @CurrentWorkspace() workspace: Workspace, + ) { + return { + data: await this.editor.attributeSchema( + workspace.id, + requiredUuid('profile_id', profileId), + requiredUuid('X-Atlas-Platform-Id', platformId), + optionalUuid('category_id', categoryId), + ), + }; + } + + @Post('product-platform-profiles/:profileId/attribute-schema-syncs') + async attributeSchemaSync( + @Param('profileId') profileId: string, + @Query('category_id') categoryId: string | undefined, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @Headers('idempotency-key') key: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + @Res({ passthrough: true }) response: Response, + ) { + const result = await this.editor.requestAttributeSchemaSync( + workspace.id, + request.auth.userId, + requiredUuid('profile_id', profileId), + requiredUuid('X-Atlas-Platform-Id', platformId), + requiredUuid('Idempotency-Key', key), + requiredUuid('category_id', categoryId), + ); + response.status(result.status); + return result.body; + } + + @Get( + 'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId', + ) + async attributeDictionary( + @Param('profileId') profileId: string, + @Param('dictionaryId') dictionaryId: string, + @Query('q') query: string | undefined, + @Query('category_id') categoryId: string | undefined, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @CurrentWorkspace() workspace: Workspace, + ) { + if (!dictionaryId.trim() || dictionaryId.length > 200) + throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法'); + return { + data: await this.editor.attributeDictionary( + workspace.id, + requiredUuid('profile_id', profileId), + requiredUuid('X-Atlas-Platform-Id', platformId), + dictionaryId, + query, + optionalUuid('category_id', categoryId), + ), + }; + } + + @Post( + 'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs', + ) + async attributeDictionarySync( + @Param('profileId') profileId: string, + @Param('dictionaryId') dictionaryId: string, + @Query('category_id') categoryId: string | undefined, + @Query('q') query: string | undefined, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @Headers('idempotency-key') key: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + @Res({ passthrough: true }) response: Response, + ) { + if (!dictionaryId.trim() || dictionaryId.length > 200) + throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法'); + const result = await this.editor.requestAttributeDictionarySync( + workspace.id, + request.auth.userId, + requiredUuid('profile_id', profileId), + requiredUuid('X-Atlas-Platform-Id', platformId), + requiredUuid('Idempotency-Key', key), + requiredUuid('category_id', categoryId), + dictionaryId, + query ?? '', + ); + response.status(result.status); + return result.body; + } + @Put('products/:productId/editor-draft') async save( @Param('productId') productId: string, @@ -62,19 +208,28 @@ export class ProductEditorController { ) { const parsed = saveProductEditorDraftRequestSchema.safeParse(body); if (!parsed.success) { - throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', parsed.error.issues.map((issue) => ({ - field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message, - }))); + throw new ApiError( + 422, + 'VALIDATION_FAILED', + '请求参数不符合要求', + parsed.error.issues.map((issue) => ({ + field: issue.path.join('.'), + code: 'INVALID_FIELD', + message: issue.message, + })), + ); } - return (await this.editor.save( - workspace.id, - request.auth.userId, - requiredUuid('product_id', productId), - requiredUuid('X-Atlas-Platform-Id', platformId), - requiredUuid('Idempotency-Key', key), - parseEditorRevision(ifMatch), - parsed.data, - )).body; + return ( + await this.editor.save( + workspace.id, + request.auth.userId, + requiredUuid('product_id', productId), + requiredUuid('X-Atlas-Platform-Id', platformId), + requiredUuid('Idempotency-Key', key), + parseEditorRevision(ifMatch), + parsed.data, + ) + ).body; } } @@ -85,8 +240,18 @@ function requiredUuid(field: string, value: string | undefined) { ]); } +function optionalUuid(field: string, value: string | undefined) { + if (value === undefined || value === '') return undefined; + return requiredUuid(field, value); +} + function parseEditorRevision(value: string | undefined) { const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/); - if (!match) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match'); + if (!match) + throw new ApiError( + 400, + 'IF_MATCH_REQUIRED', + '请求缺少或格式错误的 If-Match', + ); return { product: Number(match[1]), profile: Number(match[2]) }; } diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 6873484..6bd9474 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -1,8 +1,15 @@ import { Inject, Injectable } from '@nestjs/common'; +import { + productAiSourceMaterialOverridesSchema, + productAiSourceMaterialSchema, +} from '@atlas/contracts'; import type { + ProductAiSourceField, + ProductAiSourceMaterial, ProductEditorContext, ProductEditorSnapshot, SaveProductEditorDraftRequest, + Operation, } from '@atlas/contracts'; import type { Database, DatabaseTransaction } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; @@ -19,6 +26,20 @@ type ProductRow = { barcode: string | null; purchase_url: string | null; supply_note: string | null; + default_physical_length_mm: string | null; + default_physical_width_mm: string | null; + default_physical_height_mm: string | null; + default_net_weight_g: string | null; + default_package_length_mm: string | null; + default_package_width_mm: string | null; + default_package_height_mm: string | null; + default_shipping_weight_g: string | null; + physical_spec_status: 'missing' | 'ambiguous' | 'confirmed'; + package_spec_status: 'missing' | 'ambiguous' | 'confirmed'; + ai_source_material_overrides: unknown; + ai_source_material_base_revision: number | string | null; + normalized_payload: unknown; + source_snapshot_revision: number | string; }; type ProfileRow = { id: string; @@ -32,10 +53,21 @@ type ProfileRow = { platform_product_category_id: string | null; platform_type_id: string | null; category_source: string | null; + category_name: string | null; + category_path: string | null; target_store_id: string; store_name: string; currency_code: string; }; +type OperationRow = { + id: string; + kind: string; + status: string; + created_at: Date | string; + completed_at: Date | string | null; + error_code: string | null; + error_message: string | null; +}; @Injectable() export class ProductEditorService { @@ -81,7 +113,11 @@ export class ProductEditorService { return { status: 200, body: { - data: this.toContext(collectionItemId, product.id, existing), + data: this.toContext( + collectionItemId, + product.id, + existing, + ), }, }; } @@ -99,16 +135,24 @@ export class ProductEditorService { RETURNING id`, [product.id, platformId, store.id], ); - const profile = - created.rows[0] - ? await this.requireProfile(transaction, created.rows[0].id) - : await this.findProfile(transaction, product.id, platformId); - if (!profile) throw new Error('商品编辑上下文创建后无法读取发布配置'); + const profile = created.rows[0] + ? await this.requireProfile(transaction, created.rows[0].id) + : await this.findProfile( + transaction, + product.id, + platformId, + ); + if (!profile) + throw new Error('商品编辑上下文创建后无法读取发布配置'); await this.ensureListings(transaction, product.id, profile); return { status: created.rows[0] ? 201 : 200, body: { - data: this.toContext(collectionItemId, product.id, profile), + data: this.toContext( + collectionItemId, + product.id, + profile, + ), }, }; }, @@ -120,131 +164,471 @@ export class ProductEditorService { productId: string, platformId: string, ): Promise { - return this.database.workspaceTransaction(workspaceId, async (transaction) => { - await this.requireTargetPlatform(transaction, platformId); - const product = await this.requireProduct(transaction, productId); - const profile = await this.findProfile(transaction, productId, platformId); - if (!profile) - throw new ApiError(409, 'EDITOR_CONTEXT_REQUIRED', '请先从采集箱进入商品编辑器'); - const variants = await transaction.query<{ - id: string; source_variant_key: string | null; source_sku: string | null; - display_name: string | null; sort_order: number; purchase_cost_cny: string | null; listing_id: string; - offer_id: string | null; status: string; selling_price: string | null; - list_price: string | null; currency_code: string | null; - }>( - `SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, + return this.database.workspaceTransaction( + workspaceId, + async (transaction) => { + await this.requireTargetPlatform(transaction, platformId); + const product = await this.requireProduct( + transaction, + productId, + ); + const profile = await this.findProfile( + transaction, + productId, + platformId, + ); + if (!profile) + throw new ApiError( + 409, + 'EDITOR_CONTEXT_REQUIRED', + '请先从采集箱进入商品编辑器', + ); + const variants = await transaction.query<{ + id: string; + source_variant_key: string | null; + source_sku: string | null; + display_name: string | null; + sort_order: number; + purchase_cost_cny: string | null; + listing_id: string; + offer_id: string | null; + status: string; + selling_price: string | null; + list_price: string | null; + currency_code: string | null; + specification_values: unknown; + physical_length_mm: string | null; + physical_width_mm: string | null; + physical_height_mm: string | null; + net_weight_g: string | null; + physical_spec_status: + 'missing' | 'ambiguous' | 'confirmed' | null; + package_length_mm: string | null; + package_width_mm: string | null; + package_height_mm: string | null; + shipping_weight_g: string | null; + package_spec_status: + 'missing' | 'ambiguous' | 'confirmed' | null; + }>( + `SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, l.status, l.selling_price::text, - l.list_price::text, l.currency_code + l.list_price::text, l.currency_code, v.specification_values, + v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text, + v.net_weight_g::text, v.physical_spec_status, + v.package_length_mm::text, v.package_width_mm::text, v.package_height_mm::text, + v.shipping_weight_g::text, v.package_spec_status FROM atlas.product_variants v JOIN atlas.product_variant_platform_listings l ON l.product_variant_id = v.id AND l.profile_id = $1 WHERE v.product_id = $2 AND v.deleted_at IS NULL ORDER BY v.sort_order, v.id`, - [profile.id, productId], - ); - const attributes = await transaction.query<{ - id: string; scope: 'common' | 'variant'; product_variant_id: string | null; - platform_attribute_id: string; complex_id: string; values: unknown; - language_code: string | null; source: string; confirmation_status: string; - }>( - `SELECT id, scope, product_variant_id, platform_attribute_id, complex_id, + [profile.id, productId], + ); + const stores = await transaction.query<{ + id: string; + name: string; + currency_code: string; + }>( + `SELECT id, name, currency_code + FROM atlas.stores + WHERE platform_id = $1 AND deleted_at IS NULL + AND status = 'active' AND credential_status = 'valid' + ORDER BY is_default DESC, name, id`, + [platformId], + ); + const listingCounts = await transaction.query<{ + total: string; + imported: string; + submitting: string; + failed: string; + }>( + `SELECT count(*)::text AS total, + count(*) FILTER (WHERE l.status = 'imported')::text AS imported, + count(*) FILTER (WHERE l.status = 'submitting')::text AS submitting, + count(*) FILTER (WHERE l.status = 'failed')::text AS failed + FROM atlas.product_variants v + JOIN atlas.product_variant_platform_listings l + ON l.product_variant_id = v.id AND l.profile_id = $1 + WHERE v.product_id = $2 AND v.deleted_at IS NULL`, + [profile.id, productId], + ); + const attributes = await transaction.query<{ + id: string; + scope: 'common' | 'variant'; + product_variant_id: string | null; + platform_attribute_id: string; + complex_id: string; + values: unknown; + language_code: string | null; + source: string; + confirmation_status: string; + }>( + `SELECT id, scope, product_variant_id, platform_attribute_id, complex_id, values, language_code, source, confirmation_status FROM atlas.product_attributes WHERE profile_id = $1 ORDER BY created_at, id`, - [profile.id], - ); - const unmapped = await transaction.query<{ - id: string; product_variant_id: string | null; - original_platform_category_id: string | null; - original_platform_attribute_id: string; original_complex_id: string; - original_name: string; values: unknown; language_code: string | null; - reason: string; status: string; - }>( - `SELECT id, product_variant_id, original_platform_category_id, + [profile.id], + ); + const unmapped = await transaction.query<{ + id: string; + product_variant_id: string | null; + original_platform_category_id: string | null; + original_platform_attribute_id: string; + original_complex_id: string; + original_name: string; + values: unknown; + language_code: string | null; + reason: string; + status: string; + }>( + `SELECT id, product_variant_id, original_platform_category_id, original_platform_attribute_id, original_complex_id, original_name, values, language_code, reason, status FROM atlas.product_unmapped_attributes WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`, - [profile.id], - ); - return { - product: { - id: product.id, - revision: Number(product.revision), - common: { - source_title: product.source_title, - brand: product.brand, - model: product.model, - barcode: product.barcode, - purchase_url: product.purchase_url, - supply_note: product.supply_note, + [profile.id], + ); + const defaultPhysicalSpec = productPhysicalSpec(product); + const defaultPackageSpec = productPackageSpec(product); + const sourceMaterialResult = productAiSourceMaterial(product); + const sourceMaterial = sourceMaterialResult.effective; + const editorVariants: ProductEditorSnapshot['variants'] = + variants.rows.map((variant) => ({ + id: variant.id, + source_variant_key: variant.source_variant_key, + source_sku: variant.source_sku, + display_name: variant.display_name, + sort_order: variant.sort_order, + purchase_cost_cny: variant.purchase_cost_cny, + specification_values: specificationValues( + variant.specification_values, + ), + physical_spec: variantPhysicalSpec(variant), + package_spec: variantPackageSpec(variant), + effective_physical_spec: effectivePhysicalSpec( + product, + variant, + ), + effective_package_spec: effectivePackageSpec( + product, + variant, + ), + listing: { + id: variant.listing_id, + offer_id: variant.offer_id, + status: variant.status, + selling_price: variant.selling_price, + list_price: variant.list_price, + currency_code: variant.currency_code, + }, + })); + return { + collection_item_id: product.collection_item_id, + product: { + id: product.id, + revision: Number(product.revision), + common: { + source_title: product.source_title, + brand: product.brand, + model: product.model, + barcode: product.barcode, + purchase_url: product.purchase_url, + supply_note: product.supply_note, + }, + default_physical_spec: defaultPhysicalSpec, + default_package_spec: defaultPackageSpec, }, - }, - profile: { - id: profile.id, - revision: Number(profile.revision), - platform_id: profile.platform_id, - market_code: profile.market_code, - fulfillment: profile.fulfillment, - target_store: { - id: profile.target_store_id, - name: profile.store_name, - currency_code: profile.currency_code, + profile: { + id: profile.id, + revision: Number(profile.revision), + platform_id: profile.platform_id, + market_code: profile.market_code, + fulfillment: profile.fulfillment, + target_store: { + id: profile.target_store_id, + name: profile.store_name, + currency_code: profile.currency_code, + }, + available_target_stores: stores.rows, + title: profile.title, + description: profile.description, + tags: Array.isArray(profile.tags) + ? profile.tags.filter( + (value): value is string => + typeof value === 'string', + ) + : [], + category: { + id: profile.platform_product_category_id, + platform_type_id: profile.platform_type_id, + source: profile.category_source, + name: profile.category_name, + path: profile.category_path, + }, + listing_summary: listingSummary(listingCounts.rows[0]), }, - title: profile.title, - description: profile.description, - tags: Array.isArray(profile.tags) - ? profile.tags.filter((value): value is string => typeof value === 'string') + variants: editorVariants, + attributes: attributes.rows.map((attribute) => ({ + id: attribute.id, + scope: attribute.scope, + variant_id: attribute.product_variant_id, + platform_attribute_id: attribute.platform_attribute_id, + complex_id: attribute.complex_id, + values: attribute.values, + language_code: attribute.language_code, + source: attribute.source, + confirmation_status: attribute.confirmation_status, + })), + unmapped_attributes: unmapped.rows.map((attribute) => ({ + id: attribute.id, + variant_id: attribute.product_variant_id, + original_platform_category_id: + attribute.original_platform_category_id, + original_platform_attribute_id: + attribute.original_platform_attribute_id, + original_complex_id: attribute.original_complex_id, + original_name: attribute.original_name, + values: attribute.values, + language_code: attribute.language_code, + reason: attribute.reason, + status: attribute.status, + })), + source_material: sourceMaterial, + source_material_state: sourceMaterialResult.state, + ai_input: { + source_material: sourceMaterial, + default_physical_spec: defaultPhysicalSpec, + default_package_spec: defaultPackageSpec, + variants: editorVariants.map((variant) => ({ + variant_id: variant.id, + display_name: variant.display_name, + specification_values: variant.specification_values, + effective_physical_spec: + variant.effective_physical_spec, + effective_package_spec: + variant.effective_package_spec, + })), + }, + warnings: unmapped.rows.length + ? [ + { + code: 'UNMAPPED_ATTRIBUTES_PENDING', + message: '存在因类目变化保留的未映射属性', + }, + ] : [], - category: { - id: profile.platform_product_category_id, - platform_type_id: profile.platform_type_id, - source: profile.category_source, - }, - }, - variants: variants.rows.map((variant) => ({ - id: variant.id, - source_variant_key: variant.source_variant_key, - source_sku: variant.source_sku, - display_name: variant.display_name, - sort_order: variant.sort_order, - purchase_cost_cny: variant.purchase_cost_cny, - listing: { - id: variant.listing_id, - offer_id: variant.offer_id, - status: variant.status, - selling_price: variant.selling_price, - list_price: variant.list_price, - currency_code: variant.currency_code, - }, - })), - attributes: attributes.rows.map((attribute) => ({ - id: attribute.id, - scope: attribute.scope, - variant_id: attribute.product_variant_id, - platform_attribute_id: attribute.platform_attribute_id, - complex_id: attribute.complex_id, - values: attribute.values, - language_code: attribute.language_code, - source: attribute.source, - confirmation_status: attribute.confirmation_status, - })), - unmapped_attributes: unmapped.rows.map((attribute) => ({ - id: attribute.id, - variant_id: attribute.product_variant_id, - original_platform_category_id: attribute.original_platform_category_id, - original_platform_attribute_id: attribute.original_platform_attribute_id, - original_complex_id: attribute.original_complex_id, - original_name: attribute.original_name, - values: attribute.values, - language_code: attribute.language_code, - reason: attribute.reason, - status: attribute.status, - })), - warnings: unmapped.rows.length - ? [{ code: 'UNMAPPED_ATTRIBUTES_PENDING', message: '存在因类目变化保留的未映射属性' }] - : [], - }; + }; + }, + ); + } + + async categoryOptions( + workspaceId: string, + profileId: string, + platformId: string, + search: string | undefined, + ) { + return this.database.workspaceTransaction( + workspaceId, + async (transaction) => { + const profile = await this.requireProfileForPlatform( + transaction, + profileId, + platformId, + ); + const needle = search?.trim() ?? ''; + const categories = await transaction.query<{ + id: string; + parent_id: string | null; + external_category_id: string; + external_type_id: string | null; + name: string; + path: string; + is_active: boolean; + }>( + `SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active + FROM atlas.platform_product_categories + WHERE platform_id = $1 + ORDER BY path, name, id + LIMIT 10000`, + [profile.platform_id], + ); + return categories.rows; + }, + ); + } + + async attributeSchema( + workspaceId: string, + profileId: string, + platformId: string, + categoryId?: string, + ) { + return this.database.workspaceTransaction( + workspaceId, + async (transaction) => { + const profile = await this.requireProfileForPlatform( + transaction, + profileId, + platformId, + ); + const selectedCategoryId = + categoryId ?? profile.platform_product_category_id; + if (!selectedCategoryId) + throw new ApiError( + 422, + 'CATEGORY_REQUIRED', + '请先选择产品类目', + ); + if (categoryId) + await this.requireActiveCategory( + transaction, + profile.platform_id, + categoryId, + ); + return this.attributeSchemaForCategory( + transaction, + profile.platform_id, + selectedCategoryId, + ); + }, + ); + } + + async attributeDictionary( + workspaceId: string, + profileId: string, + platformId: string, + dictionaryId: string, + search: string | undefined, + categoryId?: string, + ) { + return this.database.workspaceTransaction( + workspaceId, + async (transaction) => { + const profile = await this.requireProfileForPlatform( + transaction, + profileId, + platformId, + ); + const selectedCategoryId = + categoryId ?? profile.platform_product_category_id; + if (!selectedCategoryId) + throw new ApiError( + 422, + 'CATEGORY_REQUIRED', + '请先选择产品类目', + ); + if (categoryId) + await this.requireActiveCategory( + transaction, + profile.platform_id, + categoryId, + ); + const allowed = await transaction.query<{ + dictionary_id: string; + }>( + `SELECT dictionary_id + FROM atlas.platform_product_category_attributes + WHERE platform_id = $1 AND platform_product_category_id = $2 + AND dictionary_id = $3 + LIMIT 1`, + [profile.platform_id, selectedCategoryId, dictionaryId], + ); + if (!allowed.rows[0]) + throw new ApiError( + 422, + 'ATTRIBUTE_DICTIONARY_INVALID', + '字典不属于当前类目的属性', + ); + const needle = search?.trim() ?? ''; + const values = await transaction.query<{ + value_id: string; + value: string; + }>( + `SELECT external_value_id AS value_id, value + FROM atlas.platform_attribute_dictionary_values + WHERE platform_id = $1 AND dictionary_id = $2 AND is_active + AND ($3 = '' OR value ILIKE '%' || $3 || '%') + ORDER BY value, external_value_id + LIMIT 100`, + [profile.platform_id, dictionaryId, needle], + ); + return { items: values.rows, next_cursor: null }; + }, + ); + } + + /** + * Queues only a platform read operation. The browser never sees a store + * credential and continues to read the cache through the GET endpoints. + */ + async requestCategoryTreeSync( + workspaceId: string, + userId: string, + profileId: string, + platformId: string, + key: string, + ) { + return this.requestCatalogSync({ + workspaceId, + userId, + profileId, + platformId, + key, + scope: 'tree', + path: 'category-syncs', + }); + } + + async requestAttributeSchemaSync( + workspaceId: string, + userId: string, + profileId: string, + platformId: string, + key: string, + categoryId: string, + ) { + return this.requestCatalogSync({ + workspaceId, + userId, + profileId, + platformId, + key, + scope: 'attributes', + path: 'attribute-schema-syncs', + categoryId, + }); + } + + async requestAttributeDictionarySync( + workspaceId: string, + userId: string, + profileId: string, + platformId: string, + key: string, + categoryId: string, + dictionaryId: string, + query: string, + ) { + if (query.trim().length < 2) { + throw new ApiError( + 422, + 'DICTIONARY_SEARCH_REQUIRED', + '请至少输入 2 个字符后再从 Ozon 搜索字典值', + ); + } + return this.requestCatalogSync({ + workspaceId, + userId, + profileId, + platformId, + key, + scope: 'dictionary', + path: `attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`, + categoryId, + dictionaryId, + query: query.trim(), }); } @@ -264,45 +648,196 @@ export class ProductEditorService { method: 'PUT', path: `/api/products/${productId}/editor-draft`, key, - request: { if_match: expected, platform_id: platformId, ...input }, + request: { + if_match: expected, + platform_id: platformId, + ...input, + }, }, async (transaction) => { await this.requireTargetPlatform(transaction, platformId); - const product = await this.requireProduct(transaction, productId); - const profile = await this.findProfile(transaction, productId, platformId); - if (!profile) throw new ApiError(409, 'EDITOR_CONTEXT_REQUIRED', '请先从采集箱进入商品编辑器'); - await this.requireEditableStore(transaction, input.profile.target_store_id, platformId); - await this.ensureVariantIdsBelongToProduct(transaction, productId, input); - await this.assertOfferIdsAvailable(transaction, productId, platformId, input); + // Lock the current source row before comparing its snapshot + // revision and persisting overrides. A simultaneous re-capture + // then either completes first (and this request returns 409), + // or waits until this atomic draft save commits. + const product = await this.requireProduct( + transaction, + productId, + true, + ); + const profile = await this.findProfile( + transaction, + productId, + platformId, + ); + if (!profile) + throw new ApiError( + 409, + 'EDITOR_CONTEXT_REQUIRED', + '请先从采集箱进入商品编辑器', + ); + await this.requireEditableStore( + transaction, + input.profile.target_store_id, + platformId, + ); + const selectedCategoryId = + input.profile.platform_product_category_id === undefined + ? profile.platform_product_category_id + : input.profile.platform_product_category_id; + const selectedCategory = selectedCategoryId + ? await this.requireActiveCategory( + transaction, + platformId, + selectedCategoryId, + ) + : null; + const categoryChanged = + selectedCategoryId !== profile.platform_product_category_id; + const categorySource = selectedCategory + ? categoryChanged + ? 'manual' + : (input.profile.category_source ?? + profile.category_source ?? + 'manual') + : null; + if (Number(product.revision) !== expected.product) + await this.throwProductRevisionConflict( + transaction, + productId, + expected.product, + ); + if (Number(profile.revision) !== expected.profile) + await this.throwProfileRevisionConflict( + transaction, + profile.id, + expected.profile, + ); + if ( + input.ai_source_material.source_snapshot_revision !== + Number(product.source_snapshot_revision) + ) { + throw new ApiError( + 409, + 'SOURCE_SNAPSHOT_CHANGED', + '采集来源已更新,请重新加载并确认变更', + [ + { + field: 'ai_source_material.source_snapshot_revision', + code: 'SOURCE_SNAPSHOT_CHANGED', + message: `当前采集版本为 ${product.source_snapshot_revision}`, + current_revision: Number( + product.source_snapshot_revision, + ), + expected_revision: + input.ai_source_material + .source_snapshot_revision, + }, + ], + ); + } + this.assertConfirmedSpecsComplete(input); + await this.ensureVariantIdsBelongToProduct( + transaction, + productId, + input, + ); + await this.assertOfferIdsAvailable( + transaction, + productId, + platformId, + input, + ); + const sourceMaterialOverrides = productAiSourceOverrides( + input.ai_source_material, + input.ai_source_overridden_fields, + ); - const updatedProfile = await transaction.query<{ revision: number }>( + const updatedProfile = await transaction.query<{ + revision: number; + }>( `UPDATE atlas.product_platform_profiles SET target_store_id = $1, fulfillment = $2, title = $3, description = $4, - tags = $5::jsonb, revision = revision + 1, updated_at = now() - WHERE id = $6 AND product_id = $7 AND platform_id = $8 AND revision = $9 + tags = $5::jsonb, platform_product_category_id = $6, + platform_type_id = $7, category_source = $8, + revision = revision + 1, updated_at = now() + WHERE id = $9 AND product_id = $10 AND platform_id = $11 AND revision = $12 RETURNING revision`, - [input.profile.target_store_id, input.profile.fulfillment, input.profile.title, - input.profile.description, JSON.stringify(input.profile.tags), profile.id, - productId, platformId, expected.profile], + [ + input.profile.target_store_id, + input.profile.fulfillment, + input.profile.title, + input.profile.description, + JSON.stringify(input.profile.tags), + selectedCategory?.id ?? null, + selectedCategory?.external_type_id ?? null, + categorySource, + profile.id, + productId, + platformId, + expected.profile, + ], ); - if (!updatedProfile.rows[0]) await this.throwProfileRevisionConflict(transaction, profile.id, expected.profile); - const updatedProduct = await transaction.query<{ revision: number }>( + if (!updatedProfile.rows[0]) + await this.throwProfileRevisionConflict( + transaction, + profile.id, + expected.profile, + ); + const updatedProduct = await transaction.query<{ + revision: number; + }>( `UPDATE atlas.products SET source_title = $1, brand = $2, model = $3, barcode = $4, - purchase_url = $5, supply_note = $6, revision = revision + 1, updated_at = now() - WHERE id = $7 AND revision = $8 RETURNING revision`, - [input.common.source_title, input.common.brand, input.common.model, - input.common.barcode, input.common.purchase_url, input.common.supply_note, - productId, expected.product], + purchase_url = $5, supply_note = $6, + default_physical_length_mm = $7, default_physical_width_mm = $8, + default_physical_height_mm = $9, default_net_weight_g = $10, + default_package_length_mm = $11, default_package_width_mm = $12, + default_package_height_mm = $13, default_shipping_weight_g = $14, + physical_spec_status = $15, package_spec_status = $16, + ai_source_material_overrides = $17::jsonb, + ai_source_material_base_revision = $18, + revision = revision + 1, updated_at = now() + WHERE id = $19 AND revision = $20 RETURNING revision`, + [ + input.common.source_title, + input.common.brand, + input.common.model, + input.common.barcode, + input.common.purchase_url, + input.common.supply_note, + input.common.default_physical_spec.length_mm, + input.common.default_physical_spec.width_mm, + input.common.default_physical_spec.height_mm, + input.common.default_physical_spec.net_weight_g, + input.common.default_package_spec.length_mm, + input.common.default_package_spec.width_mm, + input.common.default_package_spec.height_mm, + input.common.default_package_spec.shipping_weight_g, + input.common.default_physical_spec.status, + input.common.default_package_spec.status, + JSON.stringify(sourceMaterialOverrides), + input.ai_source_material.source_snapshot_revision, + productId, + expected.product, + ], ); - if (!updatedProduct.rows[0]) await this.throwProductRevisionConflict(transaction, productId, expected.product); + if (!updatedProduct.rows[0]) + await this.throwProductRevisionConflict( + transaction, + productId, + expected.product, + ); await transaction.query( `UPDATE atlas.collection_items SET source_title = $1, revision = revision + 1, updated_at = now() WHERE id = $2`, [input.common.source_title, product.collection_item_id], ); - const createdVariants: Array<{ client_key: string; id: string }> = []; + const createdVariants: Array<{ + client_key: string; + id: string; + }> = []; const retainedIds: string[] = []; for (let index = 0; index < input.variants.length; index += 1) { const variant = input.variants[index]; @@ -311,44 +846,130 @@ export class ProductEditorService { await transaction.query( `UPDATE atlas.product_variants SET display_name = $1, source_sku = $2, purchase_cost_cny = $3, - sort_order = $4, updated_at = now() - WHERE id = $5 AND product_id = $6 AND deleted_at IS NULL`, - [variant.display_name, variant.source_sku, variant.purchase_cost_cny, - index, variant.id, productId], + specification_values = $4::jsonb, + physical_length_mm = $5, physical_width_mm = $6, + physical_height_mm = $7, net_weight_g = $8, physical_spec_status = $9, + package_length_mm = $10, package_width_mm = $11, + package_height_mm = $12, shipping_weight_g = $13, package_spec_status = $14, + sort_order = $15, updated_at = now() + WHERE id = $16 AND product_id = $17 AND deleted_at IS NULL`, + [ + variant.display_name, + variant.source_sku, + variant.purchase_cost_cny, + JSON.stringify(variant.specification_values), + variant.physical_spec.length_mm, + variant.physical_spec.width_mm, + variant.physical_spec.height_mm, + variant.physical_spec.net_weight_g, + variant.physical_spec.status, + variant.package_spec.length_mm, + variant.package_spec.width_mm, + variant.package_spec.height_mm, + variant.package_spec.shipping_weight_g, + variant.package_spec.status, + index, + variant.id, + productId, + ], ); await transaction.query( `UPDATE atlas.product_variant_platform_listings SET offer_id = $1, store_id = $2, revision = revision + 1, updated_at = now() WHERE profile_id = $3 AND product_variant_id = $4 AND status <> 'imported'`, - [variant.offer_id, input.profile.target_store_id, profile.id, variant.id], + [ + variant.offer_id, + input.profile.target_store_id, + profile.id, + variant.id, + ], ); continue; } const copied = variant.copy_from_variant_id - ? await transaction.query<{ specification_values: unknown }>( - `SELECT specification_values FROM atlas.product_variants + ? await transaction.query<{ + specification_values: unknown; + physical_length_mm: string | null; + physical_width_mm: string | null; + physical_height_mm: string | null; + net_weight_g: string | null; + physical_spec_status: string | null; + package_length_mm: string | null; + package_width_mm: string | null; + package_height_mm: string | null; + shipping_weight_g: string | null; + package_spec_status: string | null; + }>( + `SELECT specification_values, physical_length_mm::text, physical_width_mm::text, + physical_height_mm::text, net_weight_g::text, physical_spec_status, + package_length_mm::text, package_width_mm::text, package_height_mm::text, + shipping_weight_g::text, package_spec_status + FROM atlas.product_variants WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL`, - [variant.copy_from_variant_id, productId], - ) + [variant.copy_from_variant_id, productId], + ) : null; + const copiedValues = copied?.rows[0]; const inserted = await transaction.query<{ id: string }>( `INSERT INTO atlas.product_variants (workspace_id, product_id, display_name, source_sku, purchase_cost_cny, - specification_values, sort_order) + specification_values, physical_length_mm, physical_width_mm, + physical_height_mm, net_weight_g, physical_spec_status, + package_length_mm, package_width_mm, package_height_mm, shipping_weight_g, + package_spec_status, sort_order) VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, - $5::jsonb, $6) RETURNING id`, - [productId, variant.display_name, variant.source_sku, variant.purchase_cost_cny, - JSON.stringify(copied?.rows[0]?.specification_values ?? []), index], + $5::jsonb, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id`, + [ + productId, + variant.display_name, + variant.source_sku, + variant.purchase_cost_cny, + JSON.stringify( + copiedValues?.specification_values ?? + variant.specification_values, + ), + copiedValues?.physical_length_mm ?? + variant.physical_spec.length_mm, + copiedValues?.physical_width_mm ?? + variant.physical_spec.width_mm, + copiedValues?.physical_height_mm ?? + variant.physical_spec.height_mm, + copiedValues?.net_weight_g ?? + variant.physical_spec.net_weight_g, + copiedValues?.physical_spec_status ?? + variant.physical_spec.status, + copiedValues?.package_length_mm ?? + variant.package_spec.length_mm, + copiedValues?.package_width_mm ?? + variant.package_spec.width_mm, + copiedValues?.package_height_mm ?? + variant.package_spec.height_mm, + copiedValues?.shipping_weight_g ?? + variant.package_spec.shipping_weight_g, + copiedValues?.package_spec_status ?? + variant.package_spec.status, + index, + ], ); const id = inserted.rows[0].id; retainedIds.push(id); - createdVariants.push({ client_key: variant.client_key!, id }); + createdVariants.push({ + client_key: variant.client_key!, + id, + }); await transaction.query( `INSERT INTO atlas.product_variant_platform_listings (workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id, currency_code) SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code FROM atlas.stores s WHERE s.id = $5`, - [productId, profile.id, platformId, id, input.profile.target_store_id, variant.offer_id], + [ + productId, + profile.id, + platformId, + id, + input.profile.target_store_id, + variant.offer_id, + ], ); } // A removed, never-imported SKU must release its draft offer_id; @@ -368,12 +989,24 @@ export class ProductEditorService { WHERE product_id = $1 AND deleted_at IS NULL AND NOT (id = ANY($2::uuid[]))`, [productId, retainedIds], ); + await this.persistDynamicAttributes(transaction, { + productId, + profile, + platformId, + categoryId: selectedCategory?.id ?? null, + attributes: input.attributes, + retainedVariantIds: retainedIds, + }); return { status: 200, body: { data: { - product_revision: Number(updatedProduct.rows[0].revision), - profile_revision: Number(updatedProfile.rows[0].revision), + product_revision: Number( + updatedProduct.rows[0].revision, + ), + profile_revision: Number( + updatedProfile.rows[0].revision, + ), created_variants: createdVariants, warnings: [], }, @@ -396,11 +1029,505 @@ export class ProductEditorService { JOIN atlas.stores s ON s.id = $4 WHERE v.product_id = $1 AND v.deleted_at IS NULL ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`, - [productId, profile.id, profile.platform_id, profile.target_store_id], + [ + productId, + profile.id, + profile.platform_id, + profile.target_store_id, + ], ); } - private async requireTargetPlatform(transaction: DatabaseTransaction, platformId: string) { + private async persistDynamicAttributes( + transaction: DatabaseTransaction, + input: { + productId: string; + profile: ProfileRow; + platformId: string; + categoryId: string | null; + attributes: SaveProductEditorDraftRequest['attributes']; + retainedVariantIds: string[]; + }, + ) { + const existing = await transaction.query<{ + scope: 'common' | 'variant'; + product_variant_id: string | null; + platform_attribute_id: string; + complex_id: string; + values: unknown; + language_code: string | null; + source: 'manual' | 'source_capture' | 'ai_suggestion'; + confirmation_status: 'missing' | 'ambiguous' | 'confirmed'; + original_name: string; + }>( + `SELECT a.scope, a.product_variant_id, a.platform_attribute_id, + a.complex_id, a.values, a.language_code, a.source, + a.confirmation_status, + coalesce(schema.name, a.platform_attribute_id) AS original_name + FROM atlas.product_attributes a + LEFT JOIN atlas.platform_product_category_attributes schema + ON schema.platform_id = $1 + AND schema.platform_product_category_id IS NOT DISTINCT FROM $2::uuid + AND schema.platform_attribute_id = a.platform_attribute_id + AND schema.complex_id = a.complex_id + WHERE a.profile_id = $3`, + [ + input.platformId, + input.profile.platform_product_category_id, + input.profile.id, + ], + ); + if (!input.categoryId) { + if ((input.attributes?.length ?? 0) > 0) + throw new ApiError( + 422, + 'CATEGORY_REQUIRED', + '填写平台属性前请先选择产品类目', + ); + return; + } + const schema = await this.attributeSchemaForCategory( + transaction, + input.platformId, + input.categoryId, + ); + const schemaByKey = new Map( + schema.map((item) => [ + attributeKey(item.platform_attribute_id, item.complex_id), + item, + ]), + ); + const existingByKey = new Map( + existing.rows.map((attribute) => [ + savedAttributeKey(attribute), + attribute, + ]), + ); + const drafts = + input.attributes ?? + existing.rows.flatMap((attribute) => { + const values = stringValues(attribute.values); + return values.length + ? [ + { + scope: attribute.scope, + variant_id: attribute.product_variant_id, + platform_attribute_id: + attribute.platform_attribute_id, + complex_id: attribute.complex_id, + values, + language_code: attribute.language_code, + source: attribute.source, + confirmation_status: + attribute.confirmation_status, + }, + ] + : []; + }); + const submitted = new Set(); + const valid: typeof drafts = []; + const preserve = new Map(); + + for (const attribute of drafts) { + const savedKey = savedAttributeKey({ + scope: attribute.scope, + product_variant_id: attribute.variant_id, + platform_attribute_id: attribute.platform_attribute_id, + complex_id: attribute.complex_id, + language_code: attribute.language_code, + }); + if (submitted.has(savedKey)) + throw new ApiError( + 422, + 'ATTRIBUTE_DUPLICATE', + '同一属性不能重复填写', + ); + submitted.add(savedKey); + const definition = schemaByKey.get( + attributeKey( + attribute.platform_attribute_id, + attribute.complex_id, + ), + ); + if (!definition) { + const previous = existingByKey.get(savedKey); + if (previous) preserve.set(savedKey, previous); + else + throw new ApiError( + 422, + 'ATTRIBUTE_UNAVAILABLE', + '属性不属于当前类目,无法保存', + ); + continue; + } + if (attribute.scope === 'variant') { + if (!attribute.variant_id) + throw new ApiError( + 422, + 'ATTRIBUTE_VARIANT_INVALID', + 'SKU 属性必须关联当前商品中未删除的 SKU', + ); + if (!input.retainedVariantIds.includes(attribute.variant_id)) { + // A removed SKU takes its old attributes with it. Reject a + // newly fabricated reference, but do not make the normal + // “delete SKU” operation fail because the form snapshot + // still contains that SKU's previous attribute row. + if (!existingByKey.has(savedKey)) + throw new ApiError( + 422, + 'ATTRIBUTE_VARIANT_INVALID', + 'SKU 属性必须关联当前商品中未删除的 SKU', + ); + continue; + } + } + await this.assertAttributeValues( + transaction, + input.platformId, + definition, + attribute.values, + ); + valid.push(attribute); + } + + // Attribute definitions can disappear when the category changes (or a + // platform sync retires an attribute). Preserve the existing draft + // rather than allowing it to vanish simply because this save omitted it. + for (const attribute of existing.rows) { + const key = savedAttributeKey(attribute); + if ( + !schemaByKey.has( + attributeKey( + attribute.platform_attribute_id, + attribute.complex_id, + ), + ) && + (!attribute.product_variant_id || + input.retainedVariantIds.includes( + attribute.product_variant_id, + )) + ) + preserve.set(key, attribute); + } + for (const attribute of preserve.values()) { + await transaction.query( + `INSERT INTO atlas.product_unmapped_attributes + (workspace_id, product_id, profile_id, platform_id, product_variant_id, + original_platform_category_id, original_platform_attribute_id, + original_complex_id, original_name, values, language_code, reason) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, + $5, $6, $7, $8, $9::jsonb, $10, 'category_changed')`, + [ + input.productId, + input.profile.id, + input.platformId, + attribute.product_variant_id, + input.profile.platform_product_category_id, + attribute.platform_attribute_id, + attribute.complex_id, + attribute.original_name, + JSON.stringify(attribute.values), + attribute.language_code, + ], + ); + } + await transaction.query( + `DELETE FROM atlas.product_attributes WHERE profile_id = $1`, + [input.profile.id], + ); + for (const attribute of valid) { + await transaction.query( + `INSERT INTO atlas.product_attributes + (workspace_id, product_id, profile_id, product_variant_id, scope, + platform_attribute_id, complex_id, values, language_code, source, + confirmation_status) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, + $5, $6, $7::jsonb, $8, $9, $10)`, + [ + input.productId, + input.profile.id, + attribute.variant_id, + attribute.scope, + attribute.platform_attribute_id, + attribute.complex_id, + JSON.stringify(attribute.values), + attribute.language_code, + attribute.source, + attribute.confirmation_status, + ], + ); + } + } + + private async assertAttributeValues( + transaction: DatabaseTransaction, + platformId: string, + definition: Awaited< + ReturnType + >[number], + values: string[], + ) { + if ( + values.length > definition.max_value_count || + (!definition.is_collection && values.length !== 1) + ) + throw new ApiError( + 422, + 'ATTRIBUTE_VALUE_COUNT_INVALID', + `${definition.name} 的值数量不符合当前类目要求`, + ); + if ( + definition.value_type === 'integer' && + values.some((value) => !/^-?\d+$/.test(value)) + ) + throw new ApiError( + 422, + 'ATTRIBUTE_VALUE_INVALID', + `${definition.name} 必须是整数`, + ); + if ( + definition.value_type === 'decimal' && + values.some((value) => !/^-?\d+(\.\d+)?$/.test(value)) + ) + throw new ApiError( + 422, + 'ATTRIBUTE_VALUE_INVALID', + `${definition.name} 必须是数字`, + ); + if ( + definition.value_type === 'boolean' && + values.some((value) => value !== 'true' && value !== 'false') + ) + throw new ApiError( + 422, + 'ATTRIBUTE_VALUE_INVALID', + `${definition.name} 只能是是或否`, + ); + if (definition.value_type !== 'dictionary' || !definition.dictionary_id) + return; + const dictionary = await transaction.query<{ value_id: string }>( + `SELECT external_value_id AS value_id + FROM atlas.platform_attribute_dictionary_values + WHERE platform_id = $1 AND dictionary_id = $2 AND is_active + AND external_value_id = ANY($3::text[])`, + [platformId, definition.dictionary_id, values], + ); + if ( + new Set(dictionary.rows.map((item) => item.value_id)).size !== + values.length + ) + throw new ApiError( + 422, + 'ATTRIBUTE_DICTIONARY_VALUE_INVALID', + `${definition.name} 包含无效字典值`, + ); + } + + private async attributeSchemaForCategory( + transaction: DatabaseTransaction, + platformId: string, + categoryId: string, + ) { + const result = await transaction.query<{ + platform_attribute_id: string; + complex_id: string; + name: string; + value_type: + 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary'; + is_required: boolean; + is_collection: boolean; + max_value_count: number; + dictionary_id: string | null; + is_aspect: boolean; + }>( + `SELECT platform_attribute_id, complex_id, name, value_type, + is_required, is_collection, max_value_count, dictionary_id, is_aspect + FROM atlas.platform_product_category_attributes + WHERE platform_id = $1 AND platform_product_category_id = $2 + ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`, + [platformId, categoryId], + ); + return result.rows; + } + + private async requestCatalogSync(input: { + workspaceId: string; + userId: string; + profileId: string; + platformId: string; + key: string; + scope: 'tree' | 'attributes' | 'dictionary'; + path: string; + categoryId?: string; + dictionaryId?: string; + query?: string; + }) { + return this.idempotency.run( + { + workspaceId: input.workspaceId, + userId: input.userId, + method: 'POST', + path: `/api/product-platform-profiles/${input.profileId}/${input.path}`, + key: input.key, + request: { + platform_id: input.platformId, + category_id: input.categoryId ?? null, + dictionary_id: input.dictionaryId ?? null, + query: input.query ?? null, + }, + }, + async (transaction) => { + const profile = await this.requireProfileForPlatform( + transaction, + input.profileId, + input.platformId, + ); + if (input.scope !== 'tree') { + if (!input.categoryId) + throw new ApiError( + 422, + 'CATEGORY_REQUIRED', + '请先选择产品类目', + ); + await this.requireActiveCategory( + transaction, + profile.platform_id, + input.categoryId, + ); + } + if (input.scope === 'dictionary') { + const allowed = await transaction.query<{ id: string }>( + `SELECT id FROM atlas.platform_product_category_attributes + WHERE platform_id = $1 AND platform_product_category_id = $2 + AND dictionary_id = $3 LIMIT 1`, + [ + profile.platform_id, + input.categoryId, + input.dictionaryId, + ], + ); + if (!allowed.rows[0]) + throw new ApiError( + 422, + 'ATTRIBUTE_DICTIONARY_INVALID', + '字典不属于当前类目的属性', + ); + } + const source = await transaction.query<{ + store_id: string; + secret_id: string; + platform_code: string; + }>( + `SELECT store.id AS store_id, secret.id AS secret_id, platform.code AS platform_code + FROM atlas.stores store + JOIN atlas.platforms platform ON platform.id = store.platform_id + JOIN atlas.store_secrets secret ON secret.store_id = store.id + WHERE store.id = $1 AND store.platform_id = $2 + AND store.deleted_at IS NULL AND store.status <> 'disabled' + AND store.credential_status = 'valid' + AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL + LIMIT 1`, + [profile.target_store_id, profile.platform_id], + ); + const store = source.rows[0]; + if (!store) + throw new ApiError( + 422, + 'STORE_CREDENTIAL_UNAVAILABLE', + '目标店铺凭据尚未验证通过,无法同步 Ozon 类目数据', + ); + if (store.platform_code.toUpperCase() !== 'OZON') + throw new ApiError( + 422, + 'PLATFORM_CATALOG_UNSUPPORTED', + '当前平台暂不支持类目缓存同步', + ); + const kinds = { + tree: 'platform_category_tree_sync', + attributes: 'platform_category_attribute_sync', + dictionary: 'platform_attribute_dictionary_sync', + } as const; + const eventTypes = { + tree: 'platform_category_tree_sync.requested', + attributes: 'platform_category_attribute_sync.requested', + dictionary: 'platform_attribute_dictionary_sync.requested', + } as const; + const created = await transaction.query( + `INSERT INTO atlas.operations + (workspace_id, kind, resource_type, resource_id, created_by_user_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, 'product_platform_profile', $2, $3) + RETURNING id, kind, status, created_at, completed_at, error_code, error_message`, + [kinds[input.scope], profile.id, input.userId], + ); + const operation = toEditorOperation(created.rows[0]); + await transaction.query( + `INSERT INTO atlas.outbox_events + (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) + VALUES (current_setting('app.workspace_id')::uuid, 'product_platform_profile', $1, $2, $3::jsonb, $4)`, + [ + profile.id, + eventTypes[input.scope], + JSON.stringify({ + store_id: store.store_id, + secret_id: store.secret_id, + operation_id: operation.id, + platform_id: profile.platform_id, + scope: input.scope, + ...(input.categoryId + ? { category_id: input.categoryId } + : {}), + ...(input.dictionaryId + ? { dictionary_id: input.dictionaryId } + : {}), + ...(input.query ? { query: input.query } : {}), + }), + operation.id, + ], + ); + return { status: 202, body: { data: { operation } } }; + }, + ); + } + + private async requireActiveCategory( + transaction: DatabaseTransaction, + platformId: string, + categoryId: string, + ) { + const result = await transaction.query<{ + id: string; + external_type_id: string | null; + }>( + `SELECT id, external_type_id + FROM atlas.platform_product_categories + WHERE id = $1 AND platform_id = $2 AND is_active`, + [categoryId, platformId], + ); + if (!result.rows[0]) + throw new ApiError( + 422, + 'CATEGORY_INVALID', + '类目不存在、已停用或不属于当前平台', + ); + return result.rows[0]; + } + + private async requireProfileForPlatform( + transaction: DatabaseTransaction, + profileId: string, + platformId: string, + ) { + await this.requireTargetPlatform(transaction, platformId); + const profile = await this.requireProfile(transaction, profileId); + if (profile.platform_id !== platformId) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品发布配置不存在'); + return profile; + } + + private async requireTargetPlatform( + transaction: DatabaseTransaction, + platformId: string, + ) { const platform = await transaction.query( `SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' @@ -409,10 +1536,17 @@ export class ProductEditorService { [platformId], ); if (!platform.rows[0]) - throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于商品编辑'); + throw new ApiError( + 422, + 'PLATFORM_UNAVAILABLE', + '当前平台不可用于商品编辑', + ); } - private async requireDefaultStore(transaction: DatabaseTransaction, platformId: string) { + private async requireDefaultStore( + transaction: DatabaseTransaction, + platformId: string, + ) { const store = await transaction.query<{ id: string }>( `SELECT id FROM atlas.stores WHERE platform_id = $1 AND deleted_at IS NULL AND is_default @@ -421,18 +1555,31 @@ export class ProductEditorService { [platformId], ); if (!store.rows[0]) - throw new ApiError(422, 'DEFAULT_STORE_REQUIRED', '请先添加并验证当前平台的默认店铺'); + throw new ApiError( + 422, + 'DEFAULT_STORE_REQUIRED', + '请先添加并验证当前平台的默认店铺', + ); return store.rows[0]; } - private async requireEditableStore(transaction: DatabaseTransaction, storeId: string, platformId: string) { + private async requireEditableStore( + transaction: DatabaseTransaction, + storeId: string, + platformId: string, + ) { const store = await transaction.query( `SELECT id FROM atlas.stores WHERE id = $1 AND platform_id = $2 AND deleted_at IS NULL AND status = 'active' AND credential_status = 'valid'`, [storeId, platformId], ); - if (!store.rows[0]) throw new ApiError(422, 'STORE_UNAVAILABLE', '目标店铺不存在、未验证或不属于当前平台'); + if (!store.rows[0]) + throw new ApiError( + 422, + 'STORE_UNAVAILABLE', + '目标店铺不存在、未验证或不属于当前平台', + ); } private async ensureVariantIdsBelongToProduct( @@ -440,15 +1587,25 @@ export class ProductEditorService { productId: string, input: SaveProductEditorDraftRequest, ) { - const ids = input.variants.flatMap((variant) => [variant.id, variant.copy_from_variant_id].filter((id): id is string => !!id)); + const ids = input.variants.flatMap((variant) => + [variant.id, variant.copy_from_variant_id].filter( + (id): id is string => !!id, + ), + ); if (!ids.length) return; const owned = await transaction.query<{ id: string }>( `SELECT id FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL AND id = ANY($2::uuid[])`, [productId, ids], ); - if (new Set(owned.rows.map((row) => row.id)).size !== new Set(ids).size) { - throw new ApiError(422, 'VARIANT_PRODUCT_MISMATCH', 'SKU 不属于当前商品'); + if ( + new Set(owned.rows.map((row) => row.id)).size !== new Set(ids).size + ) { + throw new ApiError( + 422, + 'VARIANT_PRODUCT_MISMATCH', + 'SKU 不属于当前商品', + ); } } @@ -462,7 +1619,11 @@ export class ProductEditorService { .map((variant) => variant.offer_id) .filter((offer): offer is string => !!offer); if (new Set(offers).size !== offers.length) { - throw new ApiError(422, 'OFFER_ID_DUPLICATE', '同一商品的 SKU 货号不能重复'); + throw new ApiError( + 422, + 'OFFER_ID_DUPLICATE', + '同一商品的 SKU 货号不能重复', + ); } if (!offers.length) return; const conflicts = await transaction.query<{ offer_id: string }>( @@ -472,62 +1633,207 @@ export class ProductEditorService { [platformId, input.profile.target_store_id, productId, offers], ); if (conflicts.rows[0]) { - throw new ApiError(422, 'OFFER_ID_ALREADY_EXISTS', '目标店铺中已存在相同货号', [{ - field: 'variants', code: 'OFFER_ID_ALREADY_EXISTS', - message: `货号 ${conflicts.rows[0].offer_id} 已被其他商品使用`, - }]); + throw new ApiError( + 422, + 'OFFER_ID_ALREADY_EXISTS', + '目标店铺中已存在相同货号', + [ + { + field: 'variants', + code: 'OFFER_ID_ALREADY_EXISTS', + message: `货号 ${conflicts.rows[0].offer_id} 已被其他商品使用`, + }, + ], + ); } } - private async throwProductRevisionConflict(transaction: DatabaseTransaction, productId: string, expected: number): Promise { - const row = await transaction.query<{ revision: number }>('SELECT revision FROM atlas.products WHERE id = $1', [productId]); - if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); - throw new ApiError(409, 'VERSION_CONFLICT', '商品已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前商品版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision, expected_revision: expected }]); + private assertConfirmedSpecsComplete(input: SaveProductEditorDraftRequest) { + const check = ( + status: 'missing' | 'ambiguous' | 'confirmed' | null, + values: Array, + field: string, + ) => { + if (status === 'confirmed' && values.some((value) => !value)) { + throw new ApiError( + 422, + 'SPECIFICATION_INCOMPLETE', + '已确认的规格必须完整填写', + [ + { + field, + code: 'SPECIFICATION_INCOMPLETE', + message: '已确认的规格必须完整填写', + }, + ], + ); + } + }; + check( + input.common.default_physical_spec.status, + [ + input.common.default_physical_spec.length_mm, + input.common.default_physical_spec.width_mm, + input.common.default_physical_spec.height_mm, + input.common.default_physical_spec.net_weight_g, + ], + 'common.default_physical_spec', + ); + check( + input.common.default_package_spec.status, + [ + input.common.default_package_spec.length_mm, + input.common.default_package_spec.width_mm, + input.common.default_package_spec.height_mm, + input.common.default_package_spec.shipping_weight_g, + ], + 'common.default_package_spec', + ); + input.variants.forEach((variant, index) => { + check( + variant.physical_spec.status, + [ + variant.physical_spec.length_mm, + variant.physical_spec.width_mm, + variant.physical_spec.height_mm, + variant.physical_spec.net_weight_g, + ], + `variants.${index}.physical_spec`, + ); + check( + variant.package_spec.status, + [ + variant.package_spec.length_mm, + variant.package_spec.width_mm, + variant.package_spec.height_mm, + variant.package_spec.shipping_weight_g, + ], + `variants.${index}.package_spec`, + ); + }); } - private async throwProfileRevisionConflict(transaction: DatabaseTransaction, profileId: string, expected: number): Promise { - const row = await transaction.query<{ revision: number }>('SELECT revision FROM atlas.product_platform_profiles WHERE id = $1', [profileId]); - if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品发布配置不存在'); - throw new ApiError(409, 'VERSION_CONFLICT', '商品发布配置已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前发布配置版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision, expected_revision: expected }]); + private async throwProductRevisionConflict( + transaction: DatabaseTransaction, + productId: string, + expected: number, + ): Promise { + const row = await transaction.query<{ revision: number }>( + 'SELECT revision FROM atlas.products WHERE id = $1', + [productId], + ); + if (!row.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + throw new ApiError(409, 'VERSION_CONFLICT', '商品已被更新', [ + { + field: 'If-Match', + code: 'VERSION_CONFLICT', + message: `当前商品版本为 ${row.rows[0].revision}`, + current_revision: row.rows[0].revision, + expected_revision: expected, + }, + ]); } - private async requireProductByCollectionItem(transaction: DatabaseTransaction, collectionItemId: string) { + private async throwProfileRevisionConflict( + transaction: DatabaseTransaction, + profileId: string, + expected: number, + ): Promise { + const row = await transaction.query<{ revision: number }>( + 'SELECT revision FROM atlas.product_platform_profiles WHERE id = $1', + [profileId], + ); + if (!row.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品发布配置不存在'); + throw new ApiError(409, 'VERSION_CONFLICT', '商品发布配置已被更新', [ + { + field: 'If-Match', + code: 'VERSION_CONFLICT', + message: `当前发布配置版本为 ${row.rows[0].revision}`, + current_revision: row.rows[0].revision, + expected_revision: expected, + }, + ]); + } + + private async requireProductByCollectionItem( + transaction: DatabaseTransaction, + collectionItemId: string, + ) { const product = await transaction.query( `SELECT p.id, p.collection_item_id, p.revision, p.source_title, p.brand, - p.model, p.barcode, p.purchase_url, p.supply_note + p.model, p.barcode, p.purchase_url, p.supply_note, + p.default_physical_length_mm::text, p.default_physical_width_mm::text, + p.default_physical_height_mm::text, p.default_net_weight_g::text, + p.default_package_length_mm::text, p.default_package_width_mm::text, + p.default_package_height_mm::text, p.default_shipping_weight_g::text, + p.physical_spec_status, p.package_spec_status, + p.ai_source_material_overrides, p.ai_source_material_base_revision, + c.normalized_payload, c.source_snapshot_revision FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id WHERE c.id = $1 AND c.deleted_at IS NULL`, [collectionItemId], ); - if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); + if (!product.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); return product.rows[0]; } - private async requireProduct(transaction: DatabaseTransaction, productId: string) { + private async requireProduct( + transaction: DatabaseTransaction, + productId: string, + lockSourceSnapshot = false, + ) { const product = await transaction.query( `SELECT p.id, p.collection_item_id, p.revision, p.source_title, p.brand, - p.model, p.barcode, p.purchase_url, p.supply_note + p.model, p.barcode, p.purchase_url, p.supply_note, + p.default_physical_length_mm::text, p.default_physical_width_mm::text, + p.default_physical_height_mm::text, p.default_net_weight_g::text, + p.default_package_length_mm::text, p.default_package_width_mm::text, + p.default_package_height_mm::text, p.default_shipping_weight_g::text, + p.physical_spec_status, p.package_spec_status, + p.ai_source_material_overrides, p.ai_source_material_base_revision, + c.normalized_payload, c.source_snapshot_revision FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id - WHERE p.id = $1 AND c.deleted_at IS NULL`, + WHERE p.id = $1 AND c.deleted_at IS NULL${lockSourceSnapshot ? ' FOR UPDATE OF c' : ''}`, [productId], ); - if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + if (!product.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); return product.rows[0]; } - private async findProfile(transaction: DatabaseTransaction, productId: string, platformId: string) { - const profile = await transaction.query(`${profileSelect} - WHERE p.product_id = $1 AND p.platform_id = $2`, [productId, platformId]); + private async findProfile( + transaction: DatabaseTransaction, + productId: string, + platformId: string, + ) { + const profile = await transaction.query( + `${profileSelect} + WHERE p.product_id = $1 AND p.platform_id = $2`, + [productId, platformId], + ); return profile.rows[0] ?? null; } - private async requireProfile(transaction: DatabaseTransaction, profileId: string) { - const profile = await transaction.query(`${profileSelect} WHERE p.id = $1`, [profileId]); + private async requireProfile( + transaction: DatabaseTransaction, + profileId: string, + ) { + const profile = await transaction.query( + `${profileSelect} WHERE p.id = $1`, + [profileId], + ); if (!profile.rows[0]) throw new Error('商品发布配置不存在'); return profile.rows[0]; } - private toContext(collectionItemId: string, productId: string, profile: ProfileRow): ProductEditorContext { + private toContext( + collectionItemId: string, + productId: string, + profile: ProfileRow, + ): ProductEditorContext { return { collection_item_id: collectionItemId, product_id: productId, @@ -544,7 +1850,315 @@ export class ProductEditorService { const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code, p.fulfillment, p.title, p.description, p.tags, p.platform_product_category_id, - p.platform_type_id, p.category_source, p.target_store_id, s.name AS store_name, + 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, s.currency_code FROM atlas.product_platform_profiles p - JOIN atlas.stores s ON s.id = p.target_store_id`; + JOIN atlas.stores s ON s.id = p.target_store_id + LEFT JOIN atlas.platform_product_categories category + ON category.id = p.platform_product_category_id + AND category.platform_id = p.platform_id`; + +type VariantSpecRow = { + specification_values: unknown; + physical_length_mm: string | null; + physical_width_mm: string | null; + physical_height_mm: string | null; + net_weight_g: string | null; + physical_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null; + package_length_mm: string | null; + package_width_mm: string | null; + package_height_mm: string | null; + shipping_weight_g: string | null; + package_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null; +}; + +function productPhysicalSpec(product: ProductRow) { + return { + length_mm: product.default_physical_length_mm, + width_mm: product.default_physical_width_mm, + height_mm: product.default_physical_height_mm, + net_weight_g: product.default_net_weight_g, + status: product.physical_spec_status, + }; +} + +function productPackageSpec(product: ProductRow) { + return { + length_mm: product.default_package_length_mm, + width_mm: product.default_package_width_mm, + height_mm: product.default_package_height_mm, + shipping_weight_g: product.default_shipping_weight_g, + status: product.package_spec_status, + }; +} + +function variantPhysicalSpec(variant: VariantSpecRow) { + return { + length_mm: variant.physical_length_mm, + width_mm: variant.physical_width_mm, + height_mm: variant.physical_height_mm, + net_weight_g: variant.net_weight_g, + status: variant.physical_spec_status, + }; +} + +function variantPackageSpec(variant: VariantSpecRow) { + return { + length_mm: variant.package_length_mm, + width_mm: variant.package_width_mm, + height_mm: variant.package_height_mm, + shipping_weight_g: variant.shipping_weight_g, + status: variant.package_spec_status, + }; +} + +function effectivePhysicalSpec(product: ProductRow, variant: VariantSpecRow) { + const fallback = productPhysicalSpec(product); + const override = variantPhysicalSpec(variant); + return { + length_mm: override.length_mm ?? fallback.length_mm, + width_mm: override.width_mm ?? fallback.width_mm, + height_mm: override.height_mm ?? fallback.height_mm, + net_weight_g: override.net_weight_g ?? fallback.net_weight_g, + status: override.status ?? fallback.status, + }; +} + +function effectivePackageSpec(product: ProductRow, variant: VariantSpecRow) { + const fallback = productPackageSpec(product); + const override = variantPackageSpec(variant); + return { + length_mm: override.length_mm ?? fallback.length_mm, + width_mm: override.width_mm ?? fallback.width_mm, + height_mm: override.height_mm ?? fallback.height_mm, + shipping_weight_g: + override.shipping_weight_g ?? fallback.shipping_weight_g, + status: override.status ?? fallback.status, + }; +} + +function specificationValues(value: unknown) { + if (!Array.isArray(value)) return []; + return value.filter( + ( + item, + ): item is { + axis_key: string; + value_key: string; + value_name: string; + } => + !!item && + typeof item === 'object' && + typeof (item as { axis_key?: unknown }).axis_key === 'string' && + typeof (item as { value_key?: unknown }).value_key === 'string' && + typeof (item as { value_name?: unknown }).value_name === 'string', + ); +} + +const productAiSourceFields: ProductAiSourceField[] = [ + 'title', + 'description', + 'price', + 'brand', + 'attributes', + 'categories', +]; + +function productAiSourceMaterial(product: ProductRow) { + const latest = latestProductAiSourceMaterial(product); + const parsedOverrides = productAiSourceMaterialOverridesSchema.safeParse( + product.ai_source_material_overrides, + ); + const overrides = parsedOverrides.success ? parsedOverrides.data : {}; + const overriddenFields = productAiSourceFields.filter((field) => + Object.prototype.hasOwnProperty.call(overrides, field), + ); + const effective = productAiSourceMaterialSchema.parse({ + ...latest, + ...overrides, + source_snapshot_revision: latest.source_snapshot_revision, + }); + const baseRevision = product.ai_source_material_base_revision + ? Number(product.ai_source_material_base_revision) + : null; + return { + effective, + state: { + latest, + overridden_fields: overriddenFields, + changed_fields: overriddenFields.filter( + (field) => + JSON.stringify(effective[field]) !== + JSON.stringify(latest[field]), + ), + base_source_snapshot_revision: baseRevision, + has_source_updates: + baseRevision !== null && + baseRevision !== latest.source_snapshot_revision, + }, + }; +} + +function productAiSourceOverrides( + material: ProductAiSourceMaterial, + overriddenFields: ProductAiSourceField[], +) { + const overrides: Record = {}; + for (const field of overriddenFields) overrides[field] = material[field]; + return productAiSourceMaterialOverridesSchema.parse(overrides); +} + +function latestProductAiSourceMaterial( + product: ProductRow, +): ProductAiSourceMaterial { + const payload = objectValue(product.normalized_payload); + const common = objectValue(payload?.common); + const title = objectValue(common?.title)?.value; + const description = objectValue(common?.description)?.value; + const sourcePrice = objectValue(common?.sourcePrice); + const attributes = Array.isArray(common?.attributes) + ? common.attributes.flatMap((item) => { + const attribute = objectValue(item); + return typeof attribute?.key === 'string' && + typeof attribute.name === 'string' && + typeof attribute.value === 'string' + ? [ + { + key: attribute.key, + name: attribute.name, + value: attribute.value, + }, + ] + : []; + }) + : []; + const categories = Array.isArray(common?.categories) + ? common.categories.flatMap((item) => { + const category = objectValue(item); + return typeof category?.key === 'string' && + typeof category.name === 'string' + ? [{ key: category.key, name: category.name }] + : []; + }) + : []; + const brand = attributes.find((attribute) => + /brand|品牌/i.test(`${attribute.key} ${attribute.name}`), + )?.value; + const candidate: ProductAiSourceMaterial = { + title: typeof title === 'string' ? title : product.source_title, + description: typeof description === 'string' ? description : null, + price: + typeof sourcePrice?.amount === 'string' && + typeof sourcePrice.currencyCode === 'string' + ? { + amount: sourcePrice.amount, + currency_code: sourcePrice.currencyCode.toUpperCase(), + } + : null, + brand: brand ?? product.brand, + attributes, + categories, + source_snapshot_revision: Number(product.source_snapshot_revision), + }; + const parsed = productAiSourceMaterialSchema.safeParse(candidate); + if (parsed.success) return parsed.data; + return { + title: product.source_title, + description: null, + price: null, + brand: product.brand, + attributes: [], + categories: [], + source_snapshot_revision: Math.max( + 1, + Number(product.source_snapshot_revision) || 1, + ), + }; +} + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function attributeKey(platformAttributeId: string, complexId: string) { + return `${platformAttributeId}\u0000${complexId}`; +} + +function savedAttributeKey(input: { + scope: 'common' | 'variant'; + product_variant_id?: string | null; + variant_id?: string | null; + platform_attribute_id: string; + complex_id: string; + language_code: string | null; +}) { + return [ + input.scope, + input.product_variant_id ?? input.variant_id ?? '', + input.platform_attribute_id, + input.complex_id, + input.language_code ?? '', + ].join('\u0000'); +} + +function stringValues(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function listingSummary( + row: + | { + total: string; + imported: string; + submitting: string; + failed: string; + } + | undefined, +) { + const total = Number(row?.total ?? 0); + const imported = Number(row?.imported ?? 0); + const submitting = Number(row?.submitting ?? 0); + const failed = Number(row?.failed ?? 0); + if (total > 0 && imported === total) + return { + status: 'listed' as const, + detail: `${imported} 个 SKU 已导入`, + }; + if (imported > 0) + return { + status: 'partially_listed' as const, + detail: `${imported} 个 SKU 已导入;${total - imported} 个等待首次上架`, + }; + if (submitting > 0) + return { + status: 'not_listed' as const, + detail: `${submitting} 个 SKU 正在首次上架`, + }; + if (failed > 0) + return { + status: 'not_listed' as const, + detail: `${failed} 个 SKU 首次上架失败`, + }; + return { status: 'not_listed' as const, detail: '等待首次上架' }; +} + +function toEditorOperation(row: OperationRow): Operation { + return { + id: row.id, + kind: row.kind, + status: row.status, + created_at: new Date(row.created_at).toISOString(), + completed_at: row.completed_at + ? new Date(row.completed_at).toISOString() + : null, + error: + row.error_code && row.error_message + ? { code: row.error_code, message: row.error_message } + : null, + }; +} diff --git a/apps/api/test/extension.e2e.test.ts b/apps/api/test/extension.e2e.test.ts index d755ad8..e148009 100644 --- a/apps/api/test/extension.e2e.test.ts +++ b/apps/api/test/extension.e2e.test.ts @@ -59,6 +59,17 @@ describe('扩展授权与采集 HTTP 集成测试', () => { 'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1', [workspaceId], ); + await tx.query( + 'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [ + workspaceId, + ]); await tx.query( 'DELETE FROM atlas.product_variants WHERE workspace_id = $1', [workspaceId], @@ -168,6 +179,62 @@ describe('扩展授权与采集 HTTP 集成测试', () => { assert.equal(first.body.data.collection_item.status, 'ready_for_edit'); assert.equal(first.body.data.materialization.pending_selection, 1); assert.equal(first.body.data.materialization.queued, 0); + const platforms = await request('/api/platforms', { + headers: { Authorization: `Bearer ${webAccessToken}` }, + }); + const targetPlatform = platforms.body.data.find( + (platform: { code: string }) => platform.code === 'OZON', + ); + assert.ok(targetPlatform); + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + `INSERT INTO atlas.stores + (workspace_id, platform_id, name, platform_client_id, currency_code, + is_default, status, credential_status) + VALUES (current_setting('app.workspace_id')::uuid, $1, '复采测试店铺', + $2, 'RUB', true, 'active', 'valid')`, + [targetPlatform.id, `refresh-${randomUUID()}`], + ); + }); + const editorContext = await request( + `/api/collection-items/${first.body.data.collection_item.id}/editor-contexts`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${webAccessToken}`, + 'X-Atlas-Platform-Id': targetPlatform.id, + 'Idempotency-Key': randomUUID(), + }, + }, + ); + assert.equal(editorContext.response.status, 201); + // Simulate real draft work before the source page is collected again. + // Re-collection must neither delete this SKU nor overwrite its Atlas + // fields / platform mapping just because a newer source snapshot arrives. + await database.workspaceTransaction(workspaceId, async (tx) => { + const variant = await tx.query<{ id: string }>( + `SELECT id FROM atlas.product_variants + WHERE product_id = $1 AND source_variant_key = '123456789'`, + [first.body.data.product_id], + ); + await tx.query( + `UPDATE atlas.products SET source_title = '人工确认商品名' + WHERE id = $1`, + [first.body.data.product_id], + ); + await tx.query( + `UPDATE atlas.product_variants + SET display_name = '人工确认 SKU', purchase_cost_cny = '88.80' + WHERE id = $1`, + [variant.rows[0].id], + ); + await tx.query( + `UPDATE atlas.product_variant_platform_listings + SET offer_id = 'manual-offer-001' + WHERE product_variant_id = $1`, + [variant.rows[0].id], + ); + }); // A sibling Ozon URL has a different entered SKU. It must still reuse // the item because the submitted real-SKU sets overlap at 123456789. const refreshedPayload = { @@ -216,7 +283,8 @@ describe('扩展授权与采集 HTTP 集成测试', () => { { url: 'https://videos.example.test/refreshed-product.mp4', mediaType: 'video', - posterUrl: 'https://images.example.test/refreshed-video-poster.jpg', + posterUrl: + 'https://images.example.test/refreshed-video-poster.jpg', role: 'source_main', relation: 'product_candidate', sortOrder: 1, @@ -241,6 +309,33 @@ describe('扩展授权与采集 HTTP 集成测试', () => { ); assert.equal(second.body.data.materialization.pending_selection, 2); assert.equal(second.body.data.materialization.queued, 0); + const editorAfterRefresh = await request( + `/api/products/${first.body.data.product_id}/editor`, + { + headers: { + Authorization: `Bearer ${webAccessToken}`, + 'X-Atlas-Platform-Id': targetPlatform.id, + }, + }, + ); + assert.equal(editorAfterRefresh.response.status, 200); + assert.equal( + editorAfterRefresh.body.data.product.common.source_title, + '人工确认商品名', + ); + const originalVariant = editorAfterRefresh.body.data.variants.find( + (variant: { source_variant_key: string }) => + variant.source_variant_key === '123456789', + ); + assert.equal(originalVariant.display_name, '人工确认 SKU'); + assert.equal(originalVariant.purchase_cost_cny, '88.8000'); + assert.equal(originalVariant.listing.offer_id, 'manual-offer-001'); + const discoveredVariant = editorAfterRefresh.body.data.variants.find( + (variant: { source_variant_key: string }) => + variant.source_variant_key === '987654321', + ); + assert.ok(discoveredVariant); + assert.equal(discoveredVariant.listing.status, 'not_listed'); const inspection = await request( `/api/collection-items/${first.body.data.collection_item.id}`, { headers: { Authorization: `Bearer ${webAccessToken}` } }, @@ -269,10 +364,17 @@ describe('扩展授权与采集 HTTP 集成测试', () => { 'pending_selection', ); const videoTransfer = inspection.body.data.asset_transfers.find( - (transfer: { media_type: string }) => transfer.media_type === 'video', + (transfer: { media_type: string }) => + transfer.media_type === 'video', + ); + assert.equal( + videoTransfer?.source_url, + 'https://videos.example.test/refreshed-product.mp4', + ); + assert.equal( + videoTransfer?.poster_url, + 'https://images.example.test/refreshed-video-poster.jpg', ); - assert.equal(videoTransfer?.source_url, 'https://videos.example.test/refreshed-product.mp4'); - assert.equal(videoTransfer?.poster_url, 'https://images.example.test/refreshed-video-poster.jpg'); const eventsBeforeSelection = await database.workspaceTransaction( workspaceId, async (tx) => diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index ec76384..9a8d477 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -15,6 +15,8 @@ let userId = ''; let token = ''; let platformId = ''; let first: { collectionItemId: string; productId: string; profileId: string }; +const seededCategoryIds: string[] = []; +const seededDictionaryIds: string[] = []; async function request(path: string, init: RequestInit = {}) { const response = await fetch(`${baseUrl}${path}`, { @@ -23,7 +25,10 @@ async function request(path: string, init: RequestInit = {}) { }); return { response, - body: response.status === 204 ? undefined : ((await response.json()) as any), + body: + response.status === 204 + ? undefined + : ((await response.json()) as any), }; } @@ -44,7 +49,7 @@ async function createCollectionItem(title: string) { }; } -describe('商品编辑 M3.0 / M3.1 HTTP 集成测试', () => { +describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { beforeAll(async () => { Object.assign(process.env, { NODE_ENV: 'test', @@ -69,35 +74,97 @@ describe('商品编辑 M3.0 / M3.1 HTTP 集成测试', () => { userId = registration.body.data.user.id; workspaceId = registration.body.data.workspace.id; token = registration.body.data.access_token; - const platforms = await request('/api/platforms', { headers: headers() }); + const platforms = await request('/api/platforms', { + headers: headers(), + }); platformId = platforms.body.data[0].id; }); afterAll(async () => { await database.workspaceTransaction(workspaceId, async (tx) => { - await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]); - await tx.query('DELETE FROM atlas.product_unmapped_attributes WHERE workspace_id = $1', [workspaceId]); - await tx.query('DELETE FROM atlas.product_attributes WHERE workspace_id = $1', [workspaceId]); - await tx.query('DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1', [workspaceId]); - await tx.query('DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1', [workspaceId]); - await tx.query('DELETE FROM atlas.product_variants WHERE workspace_id = $1', [workspaceId]); - await tx.query('DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1', [workspaceId]); - await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]); - await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]); - await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [workspaceId]); + await tx.query( + 'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.outbox_events WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [ + workspaceId, + ]); + await tx.query( + 'DELETE FROM atlas.product_unmapped_attributes WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.product_attributes WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.product_variants WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.products WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.collection_items WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [ + workspaceId, + ]); }); - await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]); - await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]); + await database.query( + 'DELETE FROM atlas.workspace_memberships WHERE user_id = $1', + [userId], + ); + await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [ + workspaceId, + ]); await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]); + if (seededDictionaryIds.length) { + await database.query( + `DELETE FROM atlas.platform_attribute_dictionary_values + WHERE platform_id = $1 AND dictionary_id = ANY($2::text[])`, + [platformId, seededDictionaryIds], + ); + } + if (seededCategoryIds.length) { + await database.query( + 'DELETE FROM atlas.platform_product_categories WHERE id = ANY($1::uuid[])', + [seededCategoryIds], + ); + } await app.close(); }); it('仅在存在已验证默认店铺时创建平台编辑上下文,并返回稳定读模型', async () => { const created = await createCollectionItem('编辑器测试商品 A'); - const missingStore = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, { - method: 'POST', - headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }), - }); + const missingStore = await request( + `/api/collection-items/${created.collectionItemId}/editor-contexts`, + { + method: 'POST', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }), + }, + ); assert.equal(missingStore.response.status, 422); assert.equal(missingStore.body.error.code, 'DEFAULT_STORE_REQUIRED'); await database.workspaceTransaction(workspaceId, async (tx) => { @@ -108,25 +175,40 @@ describe('商品编辑 M3.0 / M3.1 HTTP 集成测试', () => { [platformId, `editor-${randomUUID()}`], ); }); - const entered = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, { - method: 'POST', - headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }), - }); + const entered = await request( + `/api/collection-items/${created.collectionItemId}/editor-contexts`, + { + method: 'POST', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }), + }, + ); assert.equal(entered.response.status, 201); first = { collectionItemId: created.collectionItemId, productId: created.productId, profileId: entered.body.data.profile_id, }; - const enteredAgain = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, { - method: 'POST', - headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }), - }); + const enteredAgain = await request( + `/api/collection-items/${created.collectionItemId}/editor-contexts`, + { + method: 'POST', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }), + }, + ); assert.equal(enteredAgain.response.status, 200); assert.equal(enteredAgain.body.data.profile_id, first.profileId); - const editor = await request(`/api/products/${first.productId}/editor`, { - headers: headers({ 'X-Atlas-Platform-Id': platformId }), - }); + const editor = await request( + `/api/products/${first.productId}/editor`, + { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }, + ); assert.equal(editor.response.status, 200); assert.equal(editor.body.data.product.id, first.productId); assert.equal(editor.body.data.profile.id, first.profileId); @@ -136,32 +218,49 @@ describe('商品编辑 M3.0 / M3.1 HTTP 集成测试', () => { it('数据库拒绝跨商品 SKU 与 profile 的关联,并保留未映射属性草稿', async () => { const second = await createCollectionItem('编辑器测试商品 B'); - const secondContext = await request(`/api/collection-items/${second.collectionItemId}/editor-contexts`, { - method: 'POST', - headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }), - }); + const secondContext = await request( + `/api/collection-items/${second.collectionItemId}/editor-contexts`, + { + method: 'POST', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }), + }, + ); assert.equal(secondContext.response.status, 201); - const link = await database.workspaceTransaction(workspaceId, async (tx) => { - const secondVariant = await tx.query<{ id: string }>( - 'SELECT id FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL', - [second.productId], - ); - const firstProfile = await tx.query<{ target_store_id: string }>( - 'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1', - [first.profileId], - ); - return { - variantId: secondVariant.rows[0].id, - storeId: firstProfile.rows[0].target_store_id, - }; - }); + const link = await database.workspaceTransaction( + workspaceId, + async (tx) => { + const secondVariant = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL', + [second.productId], + ); + const firstProfile = await tx.query<{ + target_store_id: string; + }>( + 'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1', + [first.profileId], + ); + return { + variantId: secondVariant.rows[0].id, + storeId: firstProfile.rows[0].target_store_id, + }; + }, + ); await assert.rejects( database.workspaceTransaction(workspaceId, async (tx) => tx.query( `INSERT INTO atlas.product_variant_platform_listings (workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id) VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5)`, - [second.productId, first.profileId, platformId, link.variantId, link.storeId], + [ + second.productId, + first.profileId, + platformId, + link.variantId, + link.storeId, + ], ), ), { code: '23503' }, @@ -176,116 +275,871 @@ describe('商品编辑 M3.0 / M3.1 HTTP 集成测试', () => { [first.productId, first.profileId, platformId], ); }); - const editor = await request(`/api/products/${first.productId}/editor`, { - headers: headers({ 'X-Atlas-Platform-Id': platformId }), - }); + const editor = await request( + `/api/products/${first.productId}/editor`, + { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }, + ); assert.equal(editor.response.status, 200); assert.equal(editor.body.data.unmapped_attributes.length, 1); - assert.equal(editor.body.data.unmapped_attributes[0].original_name, '旧类目材质'); - assert.equal(editor.body.data.warnings[0].code, 'UNMAPPED_ATTRIBUTES_PENDING'); + assert.equal( + editor.body.data.unmapped_attributes[0].original_name, + '旧类目材质', + ); + assert.equal( + editor.body.data.warnings[0].code, + 'UNMAPPED_ATTRIBUTES_PENDING', + ); }); it('原子保存草稿会更新双版本、创建 SKU,并拒绝过期版本覆盖', async () => { - const before = await request(`/api/products/${first.productId}/editor`, { - headers: headers({ 'X-Atlas-Platform-Id': platformId }), + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.collection_items + SET normalized_payload = $1::jsonb, raw_evidence = '{"kept":true}'::jsonb, + source_snapshot_revision = 3 + WHERE id = $2`, + [ + JSON.stringify({ + common: { + title: { value: '采集原始标题' }, + description: { value: '采集原始描述' }, + sourcePrice: { + amount: '79.90', + currencyCode: 'CNY', + }, + attributes: [ + { + key: 'brand', + name: '品牌', + value: '来源品牌', + }, + { + key: 'material', + name: '材质', + value: '棉', + }, + ], + categories: [{ key: 'bags', name: '箱包' }], + }, + }), + first.collectionItemId, + ], + ); }); + const before = await request( + `/api/products/${first.productId}/editor`, + { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }, + ); + assert.equal( + before.body.data.collection_item_id, + first.collectionItemId, + ); + assert.equal(before.body.data.source_material.title, '采集原始标题'); + assert.equal(before.body.data.source_material.brand, '来源品牌'); + assert.equal( + before.body.data.source_material.source_snapshot_revision, + 3, + ); + assert.deepEqual( + before.body.data.source_material_state.overridden_fields, + [], + ); const existing = before.body.data.variants[0]; const payload = { + ai_source_material: { + ...before.body.data.source_material, + title: '用户整理后的采集标题', + description: '用户整理后的采集描述', + }, + ai_source_overridden_fields: ['title', 'description'], common: { - source_title: '已由编辑器确认的商品名', brand: 'Atlas', model: null, - barcode: null, purchase_url: null, supply_note: 'M3.1 集成测试', + source_title: '已由编辑器确认的商品名', + brand: 'Atlas', + model: null, + barcode: null, + purchase_url: null, + supply_note: 'M3.1 集成测试', + default_physical_spec: { + length_mm: '100', + width_mm: '50', + height_mm: '20', + net_weight_g: '300', + status: 'confirmed', + }, + default_package_spec: { + length_mm: '120', + width_mm: '60', + height_mm: '30', + shipping_weight_g: '400', + status: 'confirmed', + }, }, profile: { target_store_id: before.body.data.profile.target_store.id, - fulfillment: 'rfbs', title: 'Ozon 测试标题', description: null, + fulfillment: 'rfbs', + title: 'Ozon 测试标题', + description: null, tags: ['测试', '草稿'], }, variants: [ { - id: existing.id, client_key: null, copy_from_variant_id: null, - display_name: '默认 SKU(已编辑)', source_sku: existing.source_sku, - purchase_cost_cny: '88.50', offer_id: 'm3-variant-a', + id: existing.id, + client_key: null, + copy_from_variant_id: null, + display_name: '默认 SKU(已编辑)', + source_sku: existing.source_sku, + specification_values: [ + { + axis_key: 'color', + value_key: 'black', + value_name: '黑色', + }, + ], + purchase_cost_cny: '88.50', + physical_spec: { + length_mm: null, + width_mm: null, + height_mm: null, + net_weight_g: null, + status: null, + }, + package_spec: { + length_mm: null, + width_mm: null, + height_mm: null, + shipping_weight_g: null, + status: null, + }, + offer_id: 'm3-variant-a', }, { - id: null, client_key: 'new-variant-b', copy_from_variant_id: existing.id, - display_name: '新增 SKU', source_sku: null, - purchase_cost_cny: '99.00', offer_id: 'm3-variant-b', + id: null, + client_key: 'new-variant-b', + copy_from_variant_id: existing.id, + display_name: '新增 SKU', + source_sku: null, + specification_values: [], + purchase_cost_cny: '99.00', + physical_spec: { + length_mm: null, + width_mm: null, + height_mm: null, + net_weight_g: null, + status: null, + }, + package_spec: { + length_mm: null, + width_mm: null, + height_mm: null, + shipping_weight_g: null, + status: null, + }, + offer_id: 'm3-variant-b', }, ], }; const key = randomUUID(); - const saved = await request(`/api/products/${first.productId}/editor-draft`, { - method: 'PUT', - headers: headers({ - 'X-Atlas-Platform-Id': platformId, - 'Idempotency-Key': key, - 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, - }), - body: JSON.stringify(payload), - }); + const saved = await request( + `/api/products/${first.productId}/editor-draft`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': key, + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(payload), + }, + ); assert.equal(saved.response.status, 200); assert.equal(saved.body.data.created_variants.length, 1); - assert.equal(saved.body.data.product_revision, before.body.data.product.revision + 1); - assert.equal(saved.body.data.profile_revision, before.body.data.profile.revision + 1); - const replay = await request(`/api/products/${first.productId}/editor-draft`, { - method: 'PUT', - headers: headers({ - 'X-Atlas-Platform-Id': platformId, - 'Idempotency-Key': key, - 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, - }), - body: JSON.stringify(payload), - }); + assert.equal( + saved.body.data.product_revision, + before.body.data.product.revision + 1, + ); + assert.equal( + saved.body.data.profile_revision, + before.body.data.profile.revision + 1, + ); + const replay = await request( + `/api/products/${first.productId}/editor-draft`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': key, + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(payload), + }, + ); assert.deepEqual(replay.body, saved.body); const after = await request(`/api/products/${first.productId}/editor`, { headers: headers({ 'X-Atlas-Platform-Id': platformId }), }); - assert.equal(after.body.data.product.common.source_title, '已由编辑器确认的商品名'); + assert.equal( + after.body.data.product.common.source_title, + '已由编辑器确认的商品名', + ); assert.equal(after.body.data.profile.title, 'Ozon 测试标题'); + assert.equal( + after.body.data.source_material.title, + '用户整理后的采集标题', + ); + assert.equal( + after.body.data.ai_input.source_material.description, + '用户整理后的采集描述', + ); + assert.deepEqual( + after.body.data.source_material_state.overridden_fields, + ['title', 'description'], + ); + assert.equal( + after.body.data.ai_input.default_physical_spec.net_weight_g, + '300.000', + ); + assert.equal( + after.body.data.ai_input.default_package_spec.shipping_weight_g, + '400.000', + ); + assert.equal( + after.body.data.ai_input.variants[0].effective_package_spec + .shipping_weight_g, + '400.000', + ); assert.equal(after.body.data.variants.length, 2); - assert.equal(after.body.data.variants[0].listing.offer_id, 'm3-variant-a'); - const stale = await request(`/api/products/${first.productId}/editor-draft`, { - method: 'PUT', - headers: headers({ - 'X-Atlas-Platform-Id': platformId, - 'Idempotency-Key': randomUUID(), - 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, - }), - body: JSON.stringify(payload), + assert.equal( + after.body.data.variants[0].listing.offer_id, + 'm3-variant-a', + ); + assert.equal( + after.body.data.product.default_package_spec.shipping_weight_g, + '400.000', + ); + assert.equal( + after.body.data.variants[0].effective_package_spec + .shipping_weight_g, + '400.000', + ); + assert.deepEqual(after.body.data.variants[1].specification_values, [ + { axis_key: 'color', value_key: 'black', value_name: '黑色' }, + ]); + const evidence = await database.workspaceTransaction( + workspaceId, + async (tx) => + tx.query<{ + normalized_payload: any; + raw_evidence: any; + }>( + 'SELECT normalized_payload, raw_evidence FROM atlas.collection_items WHERE id = $1', + [first.collectionItemId], + ), + ); + assert.equal( + evidence.rows[0].normalized_payload.common.title.value, + '采集原始标题', + ); + assert.deepEqual(evidence.rows[0].raw_evidence, { kept: true }); + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.collection_items + SET normalized_payload = jsonb_set( + jsonb_set( + normalized_payload, + '{common,title,value}', + '"复采后的来源标题"'::jsonb + ), + '{common,sourcePrice,amount}', + '"89.90"'::jsonb + ), + source_snapshot_revision = 4 + WHERE id = $1`, + [first.collectionItemId], + ); }); + const afterRefresh = await request( + `/api/products/${first.productId}/editor`, + { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }, + ); + assert.equal( + afterRefresh.body.data.source_material.title, + '用户整理后的采集标题', + ); + assert.equal( + afterRefresh.body.data.source_material.source_snapshot_revision, + 4, + ); + assert.equal( + afterRefresh.body.data.source_material_state.latest.title, + '复采后的来源标题', + ); + assert.equal( + afterRefresh.body.data.source_material.price.amount, + '89.90', + ); + assert.equal( + afterRefresh.body.data.source_material_state + .base_source_snapshot_revision, + 3, + ); + assert.equal( + afterRefresh.body.data.source_material_state.has_source_updates, + true, + ); + assert.ok( + afterRefresh.body.data.source_material_state.changed_fields.includes( + 'title', + ), + ); + const staleSource = await request( + `/api/products/${first.productId}/editor-draft`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`, + }), + body: JSON.stringify(payload), + }, + ); + assert.equal(staleSource.response.status, 409); + assert.equal(staleSource.body.error.code, 'SOURCE_SNAPSHOT_CHANGED'); + const stale = await request( + `/api/products/${first.productId}/editor-draft`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(payload), + }, + ); assert.equal(stale.response.status, 409); assert.equal(stale.body.error.code, 'VERSION_CONFLICT'); - const removeSecond = { ...payload, variants: [payload.variants[0]] }; - const removed = await request(`/api/products/${first.productId}/editor-draft`, { - method: 'PUT', - headers: headers({ - 'X-Atlas-Platform-Id': platformId, - 'Idempotency-Key': randomUUID(), - 'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`, - }), - body: JSON.stringify(removeSecond), - }); + const removeSecond = { + ...payload, + ai_source_material: afterRefresh.body.data.source_material, + ai_source_overridden_fields: + afterRefresh.body.data.source_material_state.overridden_fields, + variants: [payload.variants[0]], + }; + const removed = await request( + `/api/products/${first.productId}/editor-draft`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`, + }), + body: JSON.stringify(removeSecond), + }, + ); assert.equal(removed.response.status, 200); const replacement = { ...removeSecond, variants: [ removeSecond.variants[0], { - id: null, client_key: 'replacement-variant-b', copy_from_variant_id: existing.id, - display_name: '替代 SKU', source_sku: null, - purchase_cost_cny: '99.00', offer_id: 'm3-variant-b', + id: null, + client_key: 'replacement-variant-b', + copy_from_variant_id: existing.id, + display_name: '替代 SKU', + source_sku: null, + specification_values: [], + purchase_cost_cny: '99.00', + physical_spec: { + length_mm: null, + width_mm: null, + height_mm: null, + net_weight_g: null, + status: null, + }, + package_spec: { + length_mm: null, + width_mm: null, + height_mm: null, + shipping_weight_g: null, + status: null, + }, + offer_id: 'm3-variant-b', }, ], }; - const reusedOffer = await request(`/api/products/${first.productId}/editor-draft`, { - method: 'PUT', - headers: headers({ - 'X-Atlas-Platform-Id': platformId, - 'Idempotency-Key': randomUUID(), - 'If-Match': `"${removed.body.data.product_revision}:${removed.body.data.profile_revision}"`, - }), - body: JSON.stringify(replacement), - }); + const reusedOffer = await request( + `/api/products/${first.productId}/editor-draft`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${removed.body.data.product_revision}:${removed.body.data.profile_revision}"`, + }), + body: JSON.stringify(replacement), + }, + ); assert.equal(reusedOffer.response.status, 200); }); + + it('保存规格轴与覆盖规格,并在切换店铺时保留已导入 SKU 的原店铺', async () => { + const before = await request( + `/api/products/${first.productId}/editor`, + { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }, + ); + const primaryStoreId = before.body.data.profile.target_store + .id as string; + const existingStore = await database.workspaceTransaction( + workspaceId, + async (tx) => + tx.query<{ id: string }>( + `INSERT INTO atlas.stores + (workspace_id, platform_id, name, platform_client_id, currency_code, status, credential_status) + VALUES (current_setting('app.workspace_id')::uuid, $1, 'M3.2 第二店铺', $2, 'RUB', 'active', 'valid') + RETURNING id`, + [platformId, `editor-m32-${randomUUID()}`], + ), + ); + const secondStoreId = existingStore.rows[0].id; + const importedVariant = before.body.data.variants[0]; + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.product_variant_platform_listings + SET status = 'imported', platform_product_id = 'm3-imported-1', first_imported_at = now() + WHERE id = $1`, + [importedVariant.listing.id], + ); + }); + const payload = { + ai_source_material: before.body.data.source_material_state.latest, + ai_source_overridden_fields: [], + common: { + ...before.body.data.product.common, + default_physical_spec: + before.body.data.product.default_physical_spec, + default_package_spec: + before.body.data.product.default_package_spec, + }, + profile: { + target_store_id: secondStoreId, + fulfillment: before.body.data.profile.fulfillment, + title: before.body.data.profile.title, + description: before.body.data.profile.description, + tags: before.body.data.profile.tags, + }, + variants: before.body.data.variants.map( + (variant: any, index: number) => ({ + id: variant.id, + client_key: null, + copy_from_variant_id: null, + display_name: variant.display_name, + source_sku: variant.source_sku, + specification_values: + index === 0 + ? [ + { + axis_key: 'size', + value_key: 'm', + value_name: 'M', + }, + ] + : variant.specification_values, + purchase_cost_cny: variant.purchase_cost_cny, + physical_spec: + index === 0 + ? { + length_mm: '101', + width_mm: '51', + height_mm: '21', + net_weight_g: '301', + status: 'confirmed', + } + : variant.physical_spec, + package_spec: variant.package_spec, + offer_id: variant.listing.offer_id, + }), + ), + }; + const saved = await request( + `/api/products/${first.productId}/editor-draft`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(payload), + }, + ); + assert.equal(saved.response.status, 200); + const after = await request(`/api/products/${first.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(after.response.status, 200); + assert.equal(after.body.data.source_material.title, '复采后的来源标题'); + assert.deepEqual( + after.body.data.source_material_state.overridden_fields, + [], + ); + assert.equal( + after.body.data.source_material_state.has_source_updates, + false, + ); + assert.equal(after.body.data.profile.target_store.id, secondStoreId); + assert.equal(after.body.data.variants[0].listing.status, 'imported'); + const stores = await database.workspaceTransaction( + workspaceId, + async (tx) => + tx.query<{ product_variant_id: string; store_id: string }>( + `SELECT product_variant_id, store_id FROM atlas.product_variant_platform_listings + WHERE profile_id = $1 ORDER BY product_variant_id`, + [before.body.data.profile.id], + ), + ); + const importedListing = stores.rows.find( + (row) => row.product_variant_id === importedVariant.id, + ); + assert.equal(importedListing?.store_id, primaryStoreId); + assert.ok( + stores.rows.some( + (row) => + row.product_variant_id !== importedVariant.id && + row.store_id === secondStoreId, + ), + ); + assert.deepEqual(after.body.data.variants[0].specification_values, [ + { axis_key: 'size', value_key: 'm', value_name: 'M' }, + ]); + assert.equal( + after.body.data.variants[0].physical_spec.length_mm, + '101.000', + ); + assert.equal( + after.body.data.variants[0].effective_physical_spec.length_mm, + '101.000', + ); + }); + + it('M3.4 按当前类目返回动态属性,并将类目切换后的旧属性保留为未映射草稿', async () => { + const dictionarySuffix = randomUUID(); + const materialDictionaryId = `m34-material-${dictionarySuffix}`; + const colorDictionaryId = `m34-color-${dictionarySuffix}`; + seededDictionaryIds.push(materialDictionaryId, colorDictionaryId); + const categoryA = await database.query<{ id: string }>( + `INSERT INTO atlas.platform_product_categories + (platform_id, external_category_id, external_type_id, name, path) + VALUES ($1, $2, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱') + RETURNING id`, + [platformId, `m34-a-${randomUUID()}`], + ); + const categoryB = await database.query<{ id: string }>( + `INSERT INTO atlas.platform_product_categories + (platform_id, external_category_id, external_type_id, name, path) + VALUES ($1, $2, 'type-b', 'M3.4 背包', '箱包 › 背包 › M3.4 背包') + RETURNING id`, + [platformId, `m34-b-${randomUUID()}`], + ); + seededCategoryIds.push(categoryA.rows[0].id, categoryB.rows[0].id); + await database.query( + `INSERT INTO atlas.platform_product_category_attributes + (platform_id, platform_product_category_id, platform_attribute_id, name, + value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect) + VALUES + ($1, $2, 'material', '材质', 'dictionary', true, false, 1, $4, false), + ($1, $2, 'color', '颜色', 'dictionary', true, false, 1, $5, true), + ($1, $3, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`, + [ + platformId, + categoryA.rows[0].id, + categoryB.rows[0].id, + materialDictionaryId, + colorDictionaryId, + ], + ); + await database.query( + `INSERT INTO atlas.platform_attribute_dictionary_values + (platform_id, dictionary_id, external_value_id, value) + VALUES ($1, $2, 'cotton', '棉'), + ($1, $3, 'black', '黑色')`, + [platformId, materialDictionaryId, colorDictionaryId], + ); + const categories = await request( + `/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`, + { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }, + ); + assert.equal(categories.response.status, 200); + assert.ok( + categories.body.data.some( + (item: { id: string }) => item.id === categoryA.rows[0].id, + ), + ); + const before = await request( + `/api/products/${first.productId}/editor`, + { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }, + ); + const variants = before.body.data.variants.map((variant: any) => ({ + id: variant.id, + client_key: null, + copy_from_variant_id: null, + display_name: variant.display_name, + source_sku: variant.source_sku, + specification_values: variant.specification_values, + purchase_cost_cny: variant.purchase_cost_cny, + physical_spec: variant.physical_spec, + package_spec: variant.package_spec, + offer_id: variant.listing.offer_id, + })); + const draft = { + ai_source_material: before.body.data.source_material, + ai_source_overridden_fields: + before.body.data.source_material_state.overridden_fields, + common: { + ...before.body.data.product.common, + default_physical_spec: + before.body.data.product.default_physical_spec, + default_package_spec: + before.body.data.product.default_package_spec, + }, + profile: { + target_store_id: before.body.data.profile.target_store.id, + fulfillment: before.body.data.profile.fulfillment, + title: before.body.data.profile.title, + description: before.body.data.profile.description, + tags: before.body.data.profile.tags, + platform_product_category_id: categoryA.rows[0].id, + category_source: 'manual', + }, + attributes: [ + { + scope: 'common', + variant_id: null, + platform_attribute_id: 'material', + complex_id: '0', + values: ['cotton'], + language_code: null, + source: 'manual', + confirmation_status: 'confirmed', + }, + { + scope: 'variant', + variant_id: variants[0].id, + platform_attribute_id: 'color', + complex_id: '0', + values: ['black'], + language_code: null, + source: 'manual', + confirmation_status: 'confirmed', + }, + ], + variants, + }; + const saved = await request( + `/api/products/${first.productId}/editor-draft`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(draft), + }, + ); + assert.equal(saved.response.status, 200); + const schema = await request( + `/api/product-platform-profiles/${first.profileId}/attribute-schema?category_id=${categoryA.rows[0].id}`, + { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }, + ); + assert.equal(schema.response.status, 200); + assert.equal( + schema.body.data.find( + (item: { platform_attribute_id: string }) => + item.platform_attribute_id === 'color', + ).is_aspect, + true, + ); + const dictionary = await request( + `/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.rows[0].id}`, + { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }, + ); + assert.deepEqual(dictionary.body.data.items, [ + { value_id: 'cotton', value: '棉' }, + ]); + const afterSave = await request( + `/api/products/${first.productId}/editor`, + { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }, + ); + assert.equal( + afterSave.body.data.profile.category.id, + categoryA.rows[0].id, + ); + assert.equal(afterSave.body.data.profile.category.name, 'M3.4 收纳箱'); + assert.equal(afterSave.body.data.attributes.length, 2); + + const categoryChanged = { + ...draft, + ai_source_material: afterSave.body.data.source_material, + ai_source_overridden_fields: + afterSave.body.data.source_material_state.overridden_fields, + profile: { + ...draft.profile, + platform_product_category_id: categoryB.rows[0].id, + }, + attributes: afterSave.body.data.attributes.map( + (attribute: any) => ({ + scope: attribute.scope, + variant_id: attribute.variant_id, + platform_attribute_id: attribute.platform_attribute_id, + complex_id: attribute.complex_id, + values: attribute.values, + language_code: attribute.language_code, + source: attribute.source, + confirmation_status: attribute.confirmation_status, + }), + ), + }; + const changed = await request( + `/api/products/${first.productId}/editor-draft`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${afterSave.body.data.product.revision}:${afterSave.body.data.profile.revision}"`, + }), + body: JSON.stringify(categoryChanged), + }, + ); + assert.equal(changed.response.status, 200); + const afterCategoryChange = await request( + `/api/products/${first.productId}/editor`, + { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }, + ); + assert.equal(afterCategoryChange.body.data.attributes.length, 0); + assert.ok( + afterCategoryChange.body.data.unmapped_attributes.some( + (attribute: { original_name: string }) => + attribute.original_name === '材质', + ), + ); + assert.ok( + afterCategoryChange.body.data.unmapped_attributes.some( + (attribute: { original_name: string }) => + attribute.original_name === '颜色', + ), + ); + }); + + it('M3.5 为类目树、类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => { + const dictionaryId = `m35-dictionary-${randomUUID()}`; + const category = await database.query<{ id: string }>( + `INSERT INTO atlas.platform_product_categories + (platform_id, external_category_id, external_type_id, name, path) + VALUES ($1, $2, 'm35-type', 'M3.5 测试类目', '测试 › M3.5 测试类目') + RETURNING id`, + [platformId, `m35-category-${randomUUID()}`], + ); + seededCategoryIds.push(category.rows[0].id); + seededDictionaryIds.push(dictionaryId); + await database.query( + `INSERT INTO atlas.platform_product_category_attributes + (platform_id, platform_product_category_id, platform_attribute_id, name, + value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect) + VALUES ($1, $2, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $3, false)`, + [platformId, category.rows[0].id, dictionaryId], + ); + await database.workspaceTransaction(workspaceId, async (tx) => { + const profile = await tx.query<{ target_store_id: string }>( + 'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1', + [first.profileId], + ); + await tx.query( + `INSERT INTO atlas.store_secrets + (workspace_id, store_id, kind, secret_ciphertext, secret_nonce, + encryption_key_version, fingerprint, status) + VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key', + $2, $3, 'test', $4, 'valid')`, + [ + profile.rows[0].target_store_id, + Buffer.from('m35-test-ciphertext'), + Buffer.from('m35-test-nonce'), + randomUUID(), + ], + ); + }); + const tree = await request( + `/api/product-platform-profiles/${first.profileId}/category-syncs`, + { + method: 'POST', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }), + }, + ); + assert.equal(tree.response.status, 202); + assert.equal(tree.body.data.operation.kind, 'platform_category_tree_sync'); + const schema = await request( + `/api/product-platform-profiles/${first.profileId}/attribute-schema-syncs?category_id=${category.rows[0].id}`, + { + method: 'POST', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }), + }, + ); + assert.equal(schema.response.status, 202); + assert.equal(schema.body.data.operation.kind, 'platform_category_attribute_sync'); + const dictionary = await request( + `/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.rows[0].id}&q=%E9%BB%91%E8%89%B2`, + { + method: 'POST', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }), + }, + ); + assert.equal(dictionary.response.status, 202); + assert.equal(dictionary.body.data.operation.kind, 'platform_attribute_dictionary_sync'); + await database.workspaceTransaction(workspaceId, async (tx) => { + const events = await tx.query<{ event_type: string; event_payload: unknown }>( + `SELECT event_type, event_payload FROM atlas.outbox_events + WHERE aggregate_id = $1 AND event_type LIKE 'platform_%_sync.requested' + ORDER BY created_at`, + [first.profileId], + ); + assert.equal(events.rows.length, 3); + assert.deepEqual( + events.rows.map((event) => event.event_type), + [ + 'platform_category_tree_sync.requested', + 'platform_category_attribute_sync.requested', + 'platform_attribute_dictionary_sync.requested', + ], + ); + assert.ok( + events.rows.every( + (event) => + !JSON.stringify(event.event_payload).includes('m35-test-ciphertext'), + ), + ); + }); + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index afc906b..7b3e5b9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,17 +14,8 @@ function App() { colorPrimary: '#8b5cf6', // 全局圆角 10px,输入框/按钮/卡片等同步生效 borderRadius: 10, - // 全局正文字号 14px;输入类控件经 components 单独放大 + // 全局正文字号沿用 Ant Design 默认值 fontSize: 14, - controlHeight: 34, - controlHeightSM: 26, - }, - components: { - // 文本框:文本 16px、高度同步加高;按钮与表格/菜单等保持全局默认 - Input: { fontSize: 16, controlHeight: 40 }, - Select: { fontSize: 16, controlHeight: 40 }, - InputNumber: { fontSize: 16, controlHeight: 40 }, - DatePicker: { fontSize: 16, controlHeight: 40 }, }, }} > diff --git a/apps/web/src/pages/products/ProductEditPage.css b/apps/web/src/pages/products/ProductEditPage.css new file mode 100644 index 0000000..bff14e9 --- /dev/null +++ b/apps/web/src/pages/products/ProductEditPage.css @@ -0,0 +1,758 @@ +.product-editor { + max-width: 1500px; + margin: 0 auto; + padding-bottom: 92px; +} +.editor-header { + margin: -4px 0 20px; +} +.editor-back { + margin-left: -8px; + color: #667085; +} +.editor-header-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-top: 2px; +} +.editor-header h2.ant-typography { + margin: 0; + font-size: 24px; + letter-spacing: -0.02em; +} +.editor-eyebrow { + color: #7c3aed; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.editor-header-context { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + white-space: nowrap; +} +.editor-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 410px; + align-items: start; + gap: 20px; + margin-top: 16px; +} +.editor-main { + display: flex; + min-width: 0; + flex-direction: column; + gap: 16px; +} +.editor-section.ant-card { + border: 1px solid #e9e7ef; + border-radius: 14px; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.025); +} +.editor-section .ant-card-body { + padding: 25px 28px; +} +.editor-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 22px; +} +.editor-section-heading h4.ant-typography { + margin: 2px 0 3px; + font-size: 20px; +} +.editor-section-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 25px; + height: 25px; + margin-right: 9px; + border-radius: 7px; + background: #ede9fe; + color: #7c3aed; + font-size: 13px; + font-weight: 800; + vertical-align: 2px; +} +.attribute-item span, +.field-label { + display: block; + margin-bottom: 4px; + color: #77718a; + font-size: 12px; +} +.editor-form-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0; +} +.editor-form-grid .span-full { + grid-column: 1 / -1; +} +.editor-form-grid .span-two { + grid-column: span 2; +} +.editor-section .ant-form-item-label > label { + color: #344054; + font-weight: 600; +} +.product-editor .ant-form-item-label > label { + font-size: 16px; +} +.product-editor .ant-input, +.product-editor .ant-select-selection-item, +.product-editor .ant-select-selection-placeholder { + font-size: 16px; +} +.product-editor .ant-input:not(textarea) { + min-height: 40px; +} +.product-editor .ant-input-group-addon { + font-size: 16px; +} +.product-editor .ant-select .ant-select-selector { + min-height: 40px !important; + font-size: 16px; +} +.product-editor .ant-select-single .ant-select-selector { + height: 40px !important; +} +.product-editor .ant-select-single .ant-select-selection-item, +.product-editor .ant-select-single .ant-select-selection-placeholder { + line-height: 38px !important; +} +.product-editor .ant-select-multiple .ant-select-selector { + padding-top: 3px; + padding-bottom: 3px; +} +.package-fields { + margin-top: 22px; + padding-top: 20px; + border-top: 1px solid #f0eef4; +} +.package-fields > .ant-form-item:first-child { + width: 150px; + max-width: 100%; +} +.package-dimensions { + margin-bottom: 0; +} +.package-dimensions-controls { + display: grid; + grid-template-columns: 120px 22px 120px 22px 120px auto; + align-items: center; + gap: 7px; + width: max-content; +} +.package-dimension-separator { + color: #98a2b3; + font-size: 23px; + font-weight: 600; + text-align: center; +} +.package-dimension-unit { + color: #667085; + font-size: 16px; + line-height: 40px; +} +.category-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border: 1px solid #eae7f0; + border-radius: 10px; + background: #fcfbfd; +} +.category-row strong { + margin-right: 8px; +} +.attribute-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} +.attribute-item { + min-width: 0; + padding: 11px 13px; + border: 1px solid #eeeaf3; + border-radius: 8px; +} +.attribute-item strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.editor-standard-select { + min-height: 40px !important; + font-size: 16px !important; +} +.editor-standard-select .ant-select-content { + min-height: 38px; + font-size: 16px !important; +} +.editor-standard-select .ant-select-content-has-value, +.editor-standard-select .ant-select-placeholder, +.editor-standard-select .ant-select-input { + font-size: 16px !important; +} + +.category-select-footer { + display: flex; + justify-content: flex-end; + padding: 4px 8px 6px; + border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0); +} +.editor-standard-select.ant-select-single { + height: 40px !important; +} +.editor-standard-select.ant-select-single .ant-select-content { + height: 38px; + line-height: 38px; +} +.editor-standard-select.ant-select-single .ant-select-input { + height: 38px; + line-height: 38px !important; +} +.editor-standard-select.ant-select-multiple .ant-select-content { + min-height: 40px !important; + padding: 3px 0; +} +.editor-standard-select.ant-select-multiple .ant-select-content-item { + min-height: 32px; + display: flex; + align-items: center; +} +.editor-standard-select.ant-select-multiple .ant-select-placeholder, +.editor-standard-select.ant-select-multiple .ant-select-input { + font-size: 16px !important; +} +.editor-standard-select.ant-select-multiple .ant-select-placeholder, +.editor-standard-select.ant-select-multiple .ant-select-input { + line-height: 32px !important; +} +.dynamic-attributes { + margin-top: 24px; + padding-top: 22px; + border-top: 1px solid #eeeaf4; +} +.dynamic-attributes h5.ant-typography { + margin: 0 0 14px; + font-size: 16px; +} +.dynamic-attribute-list { + display: grid; + grid-template-columns: 1fr; +} +.dynamic-attribute-item .ant-space-compact { + width: 100%; +} +.dynamic-attribute-item .ant-btn-dangerous.ant-btn-text { + min-width: 56px; +} +.copy-subsection { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid #eeeaf4; +} +.copy-subsection .editor-section-heading { + margin-bottom: 16px; +} +.copy-store-row { + display: grid; + grid-template-columns: minmax(240px, 1fr) 170px; + gap: 16px; +} +.sku-section .ant-input { + border-radius: 8px; + font-size: 14px; +} +.sku-section textarea.ant-input { + min-height: 58px; + resize: none; +} +.sku-section .ant-input-group-addon { + border-radius: 0 8px 8px 0; + font-size: 13px; +} +.sku-section .ant-input-group .ant-input { + border-radius: 8px 0 0 8px; +} +.sku-section .ant-input:not(textarea) { + height: 32px; + min-height: 32px; +} +.sku-card-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.sku-card.ant-card { + border: 1px solid #e8e4ee; + border-radius: 12px; + box-shadow: none; +} +.sku-card .ant-card-body { + padding: 16px 18px; +} +.sku-card-top { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 28px; +} +.sku-card-index { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #eee9fe; + color: #7151df; + font-weight: 700; +} +.sku-card-summary { + display: grid; + grid-template-columns: minmax(210px, 1.55fr) 58px minmax(132px, 1fr) 104px 112px 82px auto; + gap: 14px; + align-items: start; + margin-top: 12px; +} +.sku-card-summary .ant-form-item { + margin: 0; +} +.sku-card-field, +.sku-card-value { + min-width: 0; +} +.sku-main-thumbnail { + display: flex; + width: 42px; + aspect-ratio: 3 / 4; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid #ece8f0; + border-radius: 8px; + background: #f4f2f7; + color: #90889c; + font-size: 10px; + text-align: center; +} +.sku-main-thumbnail .ant-image, +.sku-main-thumbnail img { + width: 100%; + height: 100%; +} +.sku-main-thumbnail img { + object-fit: cover; +} +.sku-image-field { + justify-self: center; +} +.sku-card-field > span, +.sku-card-value > span { + display: block; + margin-bottom: 4px; + color: #77718a; + font-size: 12px; +} +.sku-card-value strong { + display: block; + overflow: hidden; + color: #40394f; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} +.sku-card-value strong { + min-height: 32px; + padding-top: 5px; + line-height: 22px; +} +.sku-expand-button { + align-self: start; + justify-self: end; + margin-top: 23px; +} +.sku-card-expanded { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(390px, 0.92fr); + gap: 32px; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #f0edf3; +} +.sku-media-panel h5.ant-typography, +.sku-spec-panel h5.ant-typography { + margin: 0 0 12px; +} +.sku-media-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + align-items: start; +} +.sku-media-secondary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.sku-media-item { + min-width: 0; + aspect-ratio: 3 / 4; + overflow: hidden; + border: 1px solid #ece8f0; + border-radius: 8px; + background: #f5f3f7; +} +.sku-media-item .ant-image, +.sku-media-item img { + width: 100%; + height: 100%; +} +.sku-media-item img { + object-fit: contain; +} +.sku-media-empty { + display: flex; + grid-column: 1 / -1; + min-height: 190px; + align-items: center; + justify-content: center; + border: 1px dashed #d9d4df; + border-radius: 8px; + color: #90889c; + font-size: 13px; +} +.sku-spec-panel { + padding: 18px 18px 6px; + border: 1px solid #eeeaf3; + border-radius: 10px; + background: #fcfbfd; +} +.sku-spec-panel .ant-form-item { + margin-bottom: 16px; +} +.sku-spec-panel .ant-form-item-label { + flex: 0 0 86px !important; +} +.sku-spec-panel .ant-form-item-label > label { + height: 32px; + font-size: 14px; + font-weight: 600; +} +.sku-spec-panel .ant-form-item-control { + min-width: 0; +} +.sku-spec-panel > .ant-form-item:not(:has(.sku-inline-dimensions)) .ant-input-group-wrapper { + max-width: 220px; +} +.sku-inline-dimensions { + display: grid; + grid-template-columns: minmax(54px, 1fr) 16px minmax(54px, 1fr) 16px minmax(54px, 1fr) 28px; + gap: 6px; + align-items: center; +} +.sku-dimension-separator { + color: #98a2b3; + font-size: 16px; + font-weight: 600; + text-align: center; +} +.sku-dimension-unit { + color: #77718a; + font-size: 13px; + text-align: right; +} +.add-sku-row { + display: flex; + justify-content: center; + padding-top: 4px; +} +.add-sku-button { + min-width: 180px; +} +.editor-aside { + display: flex; + flex-direction: column; + gap: 16px; +} +.collected-info-panel.ant-card { + border: 1px solid #e9e7ef; + border-radius: 14px; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.025); +} +.collected-info-panel .ant-card-body { + min-height: 820px; + padding: 20px; +} +.collected-info-panel h4.ant-typography { + margin: 0; +} +.collected-info-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 17px; +} +.collected-info-heading .ant-typography-secondary { + font-size: 12px; +} +.source-update-alert { + margin-bottom: 16px; +} +.source-update-alert .ant-alert-description { + font-size: 12px; +} +.collected-info-panel .ant-input { + font-size: 14px; +} +.collected-info-panel .ant-input:not(textarea) { + height: 32px; + min-height: 32px; +} +.collected-field { + margin-top: 16px; +} +.collected-field:first-of-type { + margin-top: 0; +} +.collected-field > span { + display: block; + margin-bottom: 5px; + color: #77718a; + font-size: 12px; +} +.collected-field-label { + display: flex; + min-height: 24px; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; + color: #77718a; + font-size: 12px; +} +.collected-field-label .ant-btn { + height: 22px; + padding: 0; + font-size: 12px; +} +.source-diff-list { + display: flex; + flex-direction: column; + gap: 10px; +} +.source-diff-list strong, +.source-diff-list span { + display: block; +} +.source-diff-list span { + margin-top: 2px; + color: #667085; + font-size: 13px; +} +.collected-field > strong { + display: block; + color: #352f43; + line-height: 1.55; +} +.collected-field .ant-typography { + margin: 0; + white-space: pre-wrap; + color: #50485d; + font-size: 13px; + line-height: 1.65; +} +.collected-summary { + display: grid; + grid-template-columns: 1fr 1fr; + overflow: hidden; + border: 1px solid #ece9f1; + border-radius: 8px; +} +.collected-summary > div { + min-width: 0; + padding: 9px 10px; + border-right: 1px solid #f0edf3; + border-bottom: 1px solid #f0edf3; +} +.collected-summary > div:nth-child(2n) { + border-right: 0; +} +.collected-summary > div:nth-last-child(-n + 2) { + border-bottom: 0; +} +.collected-summary b { + display: block; + margin-bottom: 3px; + color: #77718a; + font-size: 12px; + font-weight: 500; +} +.collected-summary strong { + display: block; + overflow: hidden; + color: #352f43; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} +.collected-attributes { + border: 1px solid #ece9f1; + border-radius: 8px; +} +.collected-attributes > div { + display: grid; + grid-template-columns: minmax(100px, 0.9fr) minmax(0, 1.3fr); + gap: 12px; + padding: 9px 10px; + border-bottom: 1px solid #f0edf3; + font-size: 12px; + line-height: 1.45; +} +.collected-attributes > div:last-child { + border-bottom: 0; +} +.collected-attributes b { + color: #6b6376; + font-weight: 600; +} +.collected-attributes span { + color: #352f43; + word-break: break-word; +} +.other-section .editor-section-heading { + margin-bottom: 8px; +} +.editor-footer { + position: sticky; + bottom: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding: 13px 16px; + border: 1px solid #e4e0eb; + border-radius: 12px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 -8px 24px rgba(38, 27, 56, 0.08); + backdrop-filter: blur(8px); +} +.footer-ai-actions { + display: flex; +} +@media (max-width: 1180px) { + .editor-workspace { + grid-template-columns: 1fr; + } + .editor-aside { + position: static; + } + .collected-info-panel { + max-width: 100%; + } + .collected-info-panel .ant-card-body { + min-height: 0; + } +} +@media (max-width: 860px) { + .editor-header-main, + .editor-section-heading, + .editor-footer { + align-items: flex-start; + flex-direction: column; + } + .editor-form-grid { + grid-template-columns: 1fr; + } + .editor-form-grid .span-two { + grid-column: span 1; + } + .package-fields > .ant-form-item:first-child, + .package-dimensions-controls { + width: 100%; + } + .package-dimensions-controls { + grid-template-columns: 1fr; + } + .package-dimension-separator { + display: none; + } + .copy-store-row { + grid-template-columns: 1fr; + } + .attribute-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .sku-card-summary { + grid-template-columns: minmax(150px, 1fr) 54px repeat(2, minmax(115px, 1fr)) auto; + } + .sku-card-value { + display: none; + } + .sku-card-expanded { + grid-template-columns: 1fr; + } +} +@media (max-width: 560px) { + .editor-section .ant-card-body { + padding: 20px 16px; + } + .category-row, + .editor-footer { + align-items: stretch; + } + .category-row { + flex-direction: column; + } + .attribute-grid { + grid-template-columns: 1fr; + } + .sku-card-summary { + grid-template-columns: minmax(0, 1fr) 48px auto; + gap: 8px; + } + .sku-card-field { + display: none; + } + .sku-main-thumbnail { + width: 48px; + height: 48px; + } + .sku-media-grid { + grid-auto-rows: 72px; + } + .sku-inline-dimensions { + grid-template-columns: 1fr; + } + .sku-inline-dimensions > span { + display: none; + } + .editor-footer .ant-space { + display: flex; + width: 100%; + } + .editor-footer .ant-space-item { + flex: 1; + } + .editor-footer .ant-btn { + width: 100%; + } +} diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 5564c57..5f79698 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -1,79 +1,459 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { App as AntdApp, Alert, Button, Card, Descriptions, Divider, Form, Input, Select, Space, Spin, Typography } from 'antd'; -import { DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons'; -import type { ProductEditorSnapshot, SaveProductEditorDraftRequest } from '@atlas/contracts'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useBlocker, useNavigate, useParams } from 'react-router-dom'; +import { + Alert, + App as AntdApp, + Button, + Card, + Cascader, + Empty, + Form, + Image, + Input, + Popconfirm, + Select, + Space, + Spin, + Tag, + Tooltip, + Typography, +} from 'antd'; +import { + ArrowLeftOutlined, + CopyOutlined, + DeleteOutlined, + DownOutlined, + EditOutlined, + ExportOutlined, + FileTextOutlined, + PlusOutlined, + RobotOutlined, + SaveOutlined, + TagsOutlined, +} from '@ant-design/icons'; +import type { + CollectionItemInspection, + ProductAttributeDictionaryValue, + ProductAttributeSchema, + ProductAiSourceField, + ProductAiSourceMaterial, + ProductEditorSnapshot, + ProductEditorCategoryOption, + SaveProductEditorDraftRequest, +} from '@atlas/contracts'; import { apiErrorCode, apiErrorMessage } from '@/services/api'; import { productEditorApi } from '@/services/productEditor'; import { usePlatform } from '@/services/platform'; +import { collectionApi } from '@/services/collection'; +import './ProductEditPage.css'; -type VariantValues = { - id: string | null; - client_key: string | null; - copy_from_variant_id: string | null; - display_name: string | null; - source_sku: string | null; - purchase_cost_cny: string | null; - offer_id: string | null; -}; +type VariantValues = SaveProductEditorDraftRequest['variants'][number]; type EditorFormValues = SaveProductEditorDraftRequest; +type NamePath = Array; +type SnapshotVariant = ProductEditorSnapshot['variants'][number]; function formValues(snapshot: ProductEditorSnapshot): EditorFormValues { return { - common: snapshot.product.common, + 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 as EditorFormValues['profile']['fulfillment'], title: snapshot.profile.title, description: snapshot.profile.description, tags: snapshot.profile.tags, + platform_product_category_id: snapshot.profile.category.id, + category_source: snapshot.profile.category.source as + 'source_capture' | 'ai_suggestion' | 'manual' | null, }, + attributes: snapshot.attributes.map((attribute) => ({ + scope: attribute.scope, + variant_id: attribute.variant_id, + platform_attribute_id: attribute.platform_attribute_id, + complex_id: attribute.complex_id, + values: Array.isArray(attribute.values) + ? attribute.values.filter((value): value is string => typeof value === 'string') + : [], + language_code: attribute.language_code, + source: attribute.source as 'manual' | 'source_capture' | 'ai_suggestion', + confirmation_status: attribute.confirmation_status as + 'missing' | 'ambiguous' | 'confirmed', + })), variants: snapshot.variants.map((variant) => ({ id: variant.id, client_key: null, copy_from_variant_id: null, display_name: variant.display_name, source_sku: variant.source_sku, + specification_values: variant.specification_values, purchase_cost_cny: variant.purchase_cost_cny, + physical_spec: variant.physical_spec, + package_spec: variant.package_spec, offer_id: variant.listing.offer_id, })), }; } -function nullable(value: string | null | undefined): string | null { +function nullable(value: string | null | undefined) { const trimmed = value?.trim(); - return trimmed ? trimmed : null; + return trimmed || null; +} + +const wait = (milliseconds: number) => + new Promise((resolve) => window.setTimeout(resolve, milliseconds)); + +type CategoryTreeOption = { + value: string; + label: string; + disabled?: boolean; + children?: CategoryTreeOption[]; +}; + +function categoryTree(options: ProductEditorCategoryOption[]) { + const nodes = new Map(); + const paths = new Map(); + for (const category of options) { + nodes.set(category.id, { + value: category.id, + label: category.name, + ...(category.is_active ? {} : { disabled: true }), + children: [], + }); + } + const roots: CategoryTreeOption[] = []; + for (const category of options) { + const node = nodes.get(category.id)!; + const parent = category.parent_id + ? nodes.get(category.parent_id) + : undefined; + if (parent) parent.children!.push(node); + else roots.push(node); + } + for (const category of options) { + const path: string[] = []; + const visited = new Set(); + let current: ProductEditorCategoryOption | undefined = category; + while (current && !visited.has(current.id)) { + visited.add(current.id); + path.unshift(current.id); + current = current.parent_id + ? options.find((item) => item.id === current!.parent_id) + : undefined; + } + paths.set(category.id, path); + } + return { options: roots, paths }; } function normalizedValues(values: EditorFormValues): EditorFormValues { + const physical = < + T extends { + length_mm: string | null; + width_mm: string | null; + height_mm: string | null; + net_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), + 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), + }); return { ...values, common: { - source_title: nullable(values.common.source_title), brand: nullable(values.common.brand), - model: nullable(values.common.model), barcode: nullable(values.common.barcode), - purchase_url: nullable(values.common.purchase_url), supply_note: nullable(values.common.supply_note), + source_title: nullable(values.common.source_title), + brand: nullable(values.common.brand), + model: nullable(values.common.model), + barcode: nullable(values.common.barcode), + purchase_url: nullable(values.common.purchase_url), + supply_note: nullable(values.common.supply_note), + default_physical_spec: physical(values.common.default_physical_spec), + default_package_spec: packageSpec(values.common.default_package_spec), }, profile: { - ...values.profile, title: nullable(values.profile.title), description: nullable(values.profile.description), + ...values.profile, + title: nullable(values.profile.title), + description: nullable(values.profile.description), tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean), }, variants: values.variants.map((variant) => ({ - ...variant, display_name: nullable(variant.display_name), source_sku: nullable(variant.source_sku), - purchase_cost_cny: nullable(variant.purchase_cost_cny), offer_id: nullable(variant.offer_id), + ...variant, + display_name: nullable(variant.display_name), + source_sku: nullable(variant.source_sku), + purchase_cost_cny: nullable(variant.purchase_cost_cny), + offer_id: nullable(variant.offer_id), + specification_values: variant.specification_values.map((item) => ({ + axis_key: item.axis_key.trim(), + value_key: item.value_key.trim(), + value_name: item.value_name.trim(), + })), + physical_spec: physical(variant.physical_spec), + package_spec: packageSpec(variant.package_spec), })), }; } -/** M3.1 正式编辑入口:产品、当前平台文案与 SKU 基础数据一次性原子保存。 */ +function statusTag(status: string) { + const label = + ( + { + not_listed: '未上架', + submitting: '处理中', + imported: '已上架', + failed: '上架失败', + } as Record + )[status] ?? status; + const color = ( + { imported: 'success', failed: 'error', submitting: 'processing' } as Record + )[status]; + return {label}; +} +function sourceValue(value: unknown): string { + if (typeof value === 'string') return value || '—'; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return value.map(sourceValue).join('、') || '—'; + return value ? JSON.stringify(value) : '—'; +} + +type CollectedAttribute = { key: string; name: string; value: string }; + +function collectedSummary(facts: ProductAiSourceMaterial) { + const find = (matches: (label: string) => boolean) => + facts.attributes.find((attribute) => + matches(`${attribute.key} ${attribute.name}`.toLowerCase()) + )?.value ?? '—'; + const dimension = (packageOnly: boolean) => + find((label) => { + const isPackage = /package|packaging|parcel|shipping|包装|包裹|外箱/.test(label); + return ( + isPackage === packageOnly && + /dimension|size|length|width|height|尺寸|长宽高/.test(label) + ); + }); + const weight = (packageOnly: boolean) => + find((label) => { + const isPackage = /package|packaging|parcel|shipping|包装|包裹|外箱/.test(label); + return isPackage === packageOnly && /weight|重量|质量/.test(label); + }); + const dimensions = (value: string) => { + const parsed = millimetres(value); + return parsed ? `${parsed.join(' × ')} mm` : value; + }; + const grams = (value: string) => { + const parsed = numberInGrams(value); + return parsed ? `${parsed} g` : value; + }; + return { + price: facts.price ? `${facts.price.amount} ${facts.price.currency_code}` : '—', + brand: facts.brand ?? find((label) => /brand|品牌/.test(label)), + physicalDimensions: dimensions(dimension(false)), + physicalWeight: grams(weight(false)), + packageDimensions: dimensions(dimension(true)), + packageWeight: grams(weight(true)), + }; +} + +function numberInGrams(value: string): string | null { + const match = value.replace(',', '.').match(/\d+(?:\.\d+)?/); + if (!match) return null; + const amount = Number(match[0]); + if (!Number.isFinite(amount) || amount <= 0) return null; + return /(?:kg|千克|公斤)/i.test(value) ? String(amount * 1000) : String(amount); +} + +function millimetres(value: string): [string, string, string] | null { + const parts = value.replace(',', '.').match(/\d+(?:\.\d+)?/g); + if (!parts || parts.length < 3) return null; + const factor = /(?:cm|厘米|公分)/i.test(value) ? 10 : 1; + const dimensions = parts.slice(0, 3).map((part) => Number(part) * factor); + return dimensions.every((dimension) => Number.isFinite(dimension) && dimension > 0) + ? (dimensions.map(String) as [string, string, string]) + : null; +} + +function detectedSpecs(attributes: CollectedAttribute[]) { + const result: Record = {}; + for (const attribute of attributes) { + const label = `${attribute.key} ${attribute.name}`.toLowerCase(); + const isPackage = /package|packaging|parcel|shipping|包装|包裹|外箱/.test(label); + const isPhysical = /product|item|physical|商品|产品|实物/.test(label); + const isDimension = /dimension|size|length|width|height|尺寸|长宽高|规格/.test(label); + const isWeight = /weight|重量|质量/.test(label); + const dimensions = isDimension ? millimetres(attribute.value) : null; + const weight = isWeight ? numberInGrams(attribute.value) : null; + if (isPackage && dimensions) { + [result.package_length_mm, result.package_width_mm, result.package_height_mm] = + dimensions; + } + if (isPhysical && dimensions) { + [result.physical_length_mm, result.physical_width_mm, result.physical_height_mm] = + dimensions; + } + if (isPackage && weight) result.shipping_weight_g = weight; + if (isPhysical && weight) result.net_weight_g = weight; + } + return result; +} + +function variantImageUrls(recordValue: CollectionItemInspection | null, variant: SnapshotVariant) { + if (!recordValue) return []; + const sourceVariants = new Map(recordValue.variants.map((item) => [item.id, item])); + return recordValue.asset_transfers + .filter((transfer) => { + if (transfer.media_type !== 'image') return false; + if (!transfer.source_variant_id) return true; + return ( + sourceVariants.get(transfer.source_variant_id)?.source_variant_key === + variant.source_variant_key + ); + }) + .map((transfer) => transfer.source_url) + .filter((url, index, urls) => !!url && urls.indexOf(url) === index); +} + +function PackageFields({ name }: { name: NamePath }) { + return ( +
+ + + + +
+ + + + × + + + + × + + + + mm +
+
+
+ ); +} + +function InlineDimensions({ name }: { name: NamePath }) { + return ( +
+ + + + × + + + + × + + + + mm +
+ ); +} + +function SectionTitle({ + number, + title, + description, + extra, +}: { + number: string; + title: string; + description?: string; + extra?: React.ReactNode; +}) { + return ( +
+
+ + {number && {number}} + {title} + + {description && {description}} +
+ {extra} +
+ ); +} + +/** 商品 AI 编辑的前端工作台;AI、动态 schema 与素材读取会在后续服务端任务接入。 */ const ProductEditPage = () => { const { id: productId } = useParams<{ id: string }>(); const { currentPlatform } = usePlatform(); - const { message } = AntdApp.useApp(); + const { message, modal } = AntdApp.useApp(); const navigate = useNavigate(); const [form] = Form.useForm(); + const supplyUrl = Form.useWatch(['common', 'purchase_url'], form); + const selectedCategoryId = Form.useWatch(['profile', 'platform_product_category_id'], form); + const draftAttributes = Form.useWatch('attributes', form) ?? []; const [snapshot, setSnapshot] = useState(null); + const [collectionRecord, setCollectionRecord] = useState(null); + const [sourceDraft, setSourceDraft] = useState(null); + const [sourceOverriddenFields, setSourceOverriddenFields] = useState( + [] + ); + const [richContent, setRichContent] = useState(''); + const [categoryOptions, setCategoryOptions] = useState([]); + const [attributeSchema, setAttributeSchema] = useState([]); + const [dictionaryValues, setDictionaryValues] = useState< + Record + >({}); + const [dictionarySearches, setDictionarySearches] = useState>({}); + const [catalogRefresh, setCatalogRefresh] = useState(0); + const [catalogSyncing, setCatalogSyncing] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [expandedVariantKeys, setExpandedVariantKeys] = useState>(() => new Set()); + const blocker = useBlocker(dirty); + const sourceSpecsApplied = useRef(false); + const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]); const load = useCallback(async () => { if (!productId || !currentPlatform) return; @@ -81,96 +461,1408 @@ const ProductEditPage = () => { try { const next = await productEditorApi.read(productId, currentPlatform.id); setSnapshot(next); + setCollectionRecord(null); + setSourceDraft(next.source_material); + setSourceOverriddenFields(next.source_material_state.overridden_fields); + sourceSpecsApplied.current = false; form.setFieldsValue(formValues(next)); + setDirty(false); } catch (error) { message.error(apiErrorMessage(error)); } finally { setLoading(false); } }, [currentPlatform, form, message, productId]); + useEffect(() => { + void load(); + }, [load]); + useEffect(() => { + if (!snapshot || !currentPlatform) return; + let active = true; + void productEditorApi + .categoryOptions(snapshot.profile.id, currentPlatform.id) + .then((options) => { + if (active) setCategoryOptions(options); + }) + .catch(() => { + if (active) setCategoryOptions([]); + }); + return () => { + active = false; + }; + }, [catalogRefresh, currentPlatform, snapshot]); + useEffect(() => { + if (!snapshot || !currentPlatform || !selectedCategoryId) { + setAttributeSchema([]); + setDictionaryValues({}); + return; + } + let active = true; + void productEditorApi + .attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId) + .then((schema) => { + if (active) setAttributeSchema(schema); + }) + .catch(() => { + if (active) setAttributeSchema([]); + }); + return () => { + active = false; + }; + }, [catalogRefresh, currentPlatform, selectedCategoryId, snapshot]); + useEffect(() => { + if (!snapshot || !currentPlatform || !selectedCategoryId) return; + const dictionaryIds = [ + ...new Set( + attributeSchema + .map((attribute) => attribute.dictionary_id) + .filter((dictionaryId): dictionaryId is string => !!dictionaryId) + ), + ]; + if (!dictionaryIds.length) { + setDictionaryValues({}); + return; + } + let active = true; + void Promise.all( + dictionaryIds.map( + async (dictionaryId) => + [ + dictionaryId, + await productEditorApi.attributeDictionary( + snapshot.profile.id, + currentPlatform.id, + dictionaryId, + undefined, + selectedCategoryId + ), + ] as const + ) + ) + .then((results) => { + if (!active) return; + setDictionaryValues( + Object.fromEntries(results.map(([id, page]) => [id, page.items])) + ); + }) + .catch(() => { + if (active) setDictionaryValues({}); + }); + return () => { + active = false; + }; + }, [attributeSchema, catalogRefresh, currentPlatform, selectedCategoryId, snapshot]); + useEffect(() => { + if (!snapshot || !currentPlatform) return; + let active = true; + void (async () => { + try { + const inspection = await collectionApi.inspection(snapshot.collection_item_id); + if (active) setCollectionRecord(inspection); + } catch { + // 采集证据是辅助信息;读取失败不影响编辑草稿。 + } + })(); + return () => { + active = false; + }; + }, [currentPlatform, snapshot]); + const collected = sourceDraft ?? snapshot?.source_material ?? null; + useEffect(() => { + if (sourceSpecsApplied.current || !collected) return; + const specs = detectedSpecs(collected.attributes); + const physical = form.getFieldValue(['common', 'default_physical_spec']); + const packageSpec = form.getFieldValue(['common', 'default_package_spec']); + const hasAppliedValue = [ + [physical?.length_mm, specs.physical_length_mm], + [physical?.width_mm, specs.physical_width_mm], + [physical?.height_mm, specs.physical_height_mm], + [physical?.net_weight_g, specs.net_weight_g], + [packageSpec?.length_mm, specs.package_length_mm], + [packageSpec?.width_mm, specs.package_width_mm], + [packageSpec?.height_mm, specs.package_height_mm], + [packageSpec?.shipping_weight_g, specs.shipping_weight_g], + ].some(([current, detected]) => !current && !!detected); + if (hasAppliedValue) { + form.setFieldsValue({ + common: { + default_physical_spec: { + ...physical, + length_mm: physical?.length_mm ?? specs.physical_length_mm ?? null, + width_mm: physical?.width_mm ?? specs.physical_width_mm ?? null, + height_mm: physical?.height_mm ?? specs.physical_height_mm ?? null, + net_weight_g: physical?.net_weight_g ?? specs.net_weight_g ?? null, + }, + default_package_spec: { + ...packageSpec, + length_mm: packageSpec?.length_mm ?? specs.package_length_mm ?? null, + width_mm: packageSpec?.width_mm ?? specs.package_width_mm ?? null, + height_mm: packageSpec?.height_mm ?? specs.package_height_mm ?? null, + shipping_weight_g: + packageSpec?.shipping_weight_g ?? specs.shipping_weight_g ?? null, + }, + }, + }); + setDirty(true); + } + sourceSpecsApplied.current = true; + }, [collected, form]); + useEffect(() => { + const onBeforeUnload = (event: BeforeUnloadEvent) => { + if (dirty) { + event.preventDefault(); + event.returnValue = ''; + } + }; + window.addEventListener('beforeunload', onBeforeUnload); + return () => window.removeEventListener('beforeunload', onBeforeUnload); + }, [dirty]); + useEffect(() => { + if (blocker.state !== 'blocked') return; + modal.confirm({ + title: '放弃未保存修改?', + content: '离开后本页尚未保存的商品、SKU 与规格修改将丢失。', + okText: '放弃并离开', + okButtonProps: { danger: true }, + cancelText: '继续编辑', + onOk: () => blocker.proceed(), + onCancel: () => blocker.reset(), + }); + }, [blocker, modal]); - useEffect(() => { void load(); }, [load]); - + const leave = () => navigate('/collection'); const save = async (values: EditorFormValues) => { if (!productId || !currentPlatform || !snapshot) return; setSaving(true); try { - await productEditorApi.save(productId, currentPlatform.id, { - product: snapshot.product.revision, - profile: snapshot.profile.revision, - }, normalizedValues(values)); + 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, + }) + ); message.success('草稿已保存'); + setDirty(false); await load(); } catch (error) { - if (apiErrorCode(error) === 'VERSION_CONFLICT') { + if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') { + message.warning('采集来源已更新,已重新加载合并结果,请确认后再次保存'); + await load(); + } else if (apiErrorCode(error) === 'VERSION_CONFLICT') { message.warning('商品已在其他位置更新,已重新加载最新内容'); await load(); - } else { - message.error(apiErrorMessage(error)); - } + } else message.error(apiErrorMessage(error)); } finally { setSaving(false); } }; + const showFutureFeature = (name: string) => + modal.info({ + title: `${name}将在下一阶段接入`, + content: + '本轮只实现前端工作台。生成结果会以候选形式展示,必须由用户确认才会写入草稿,不会自动覆盖当前内容。', + okText: '知道了', + }); + const validSupplyUrl = + typeof supplyUrl === 'string' && /^https?:\/\//i.test(supplyUrl) ? supplyUrl : null; + const canOpenSupplyUrl = !!validSupplyUrl; + const openSupplyUrl = () => { + if (validSupplyUrl) window.open(validSupplyUrl, '_blank', 'noopener,noreferrer'); + }; + const updateSourceField = ( + field: Extract, + value: string + ) => { + if (!snapshot) return; + setSourceDraft({ ...(sourceDraft ?? snapshot.source_material), [field]: value }); + const latestValue = snapshot.source_material_state.latest[field]; + setSourceOverriddenFields((previous) => { + const next = new Set(previous); + if (value === latestValue) next.delete(field); + else next.add(field); + return [...next]; + }); + setDirty(true); + }; + const restoreSourceField = (field: Extract) => { + if (!snapshot) return; + setSourceDraft({ + ...(sourceDraft ?? snapshot.source_material), + [field]: snapshot.source_material_state.latest[field], + }); + setSourceOverriddenFields((previous) => previous.filter((item) => item !== field)); + setDirty(true); + }; + const acceptLatestSource = () => { + if (!snapshot) return; + setSourceDraft(snapshot.source_material_state.latest); + setSourceOverriddenFields([]); + setDirty(true); + }; + const showSourceDifferences = () => { + if (!snapshot) return; + const labels: Record = { + title: '标题', + description: '商品描述', + price: '价格', + brand: '品牌', + attributes: '规格 / 参数', + categories: '来源类目', + }; + const changed = snapshot.source_material_state.changed_fields; + modal.info({ + title: '采集信息变化', + content: changed.length ? ( +
+ {changed.map((field) => ( +
+ {labels[field]} + 当前保留用户编辑值;可恢复该字段或采用全部最新采集信息。 +
+ ))} +
+ ) : ( + '来源已产生新快照,未被用户覆盖的字段已经自动更新。' + ), + okText: '知道了', + }); + }; + const waitForCatalogOperation = async (operationId: string) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (attempt) await wait(1_000); + const operation = await productEditorApi.operation(operationId); + if (['succeeded', 'failed', 'cancelled'].includes(operation.status)) return operation; + } + return null; + }; + const runCatalogSync = async ( + syncKey: string, + action: () => Promise<{ operation: { id: string } }>, + successMessage: string + ) => { + setCatalogSyncing(syncKey); + try { + const queued = await action(); + const completed = await waitForCatalogOperation(queued.operation.id); + if (completed?.status === 'succeeded') { + message.success(successMessage); + setCatalogRefresh((version) => version + 1); + } else if (completed?.status === 'failed') { + message.error(completed.error?.message ?? 'Ozon 同步失败,请稍后重试'); + } else if (completed?.status === 'cancelled') { + message.warning(completed.error?.message ?? '同步已取消,请重新发起'); + } else { + message.info('同步仍在队列中,稍后刷新页面即可读取最新缓存'); + } + } catch (error) { + message.error(apiErrorMessage(error)); + } finally { + setCatalogSyncing(null); + } + }; + const syncAttributeSchema = () => { + if (!snapshot || !currentPlatform || !selectedCategoryId) return; + void runCatalogSync( + `attributes:${selectedCategoryId}`, + () => + productEditorApi.syncAttributeSchema( + snapshot.profile.id, + currentPlatform.id, + selectedCategoryId + ), + '该类目的属性已同步', + ); + }; + const loadDictionaryValues = async (dictionaryId: string, query = '') => { + if (!snapshot || !currentPlatform || !selectedCategoryId) return; + const page = await productEditorApi.attributeDictionary( + snapshot.profile.id, + currentPlatform.id, + dictionaryId, + query, + selectedCategoryId + ); + setDictionaryValues((current) => ({ ...current, [dictionaryId]: page.items })); + }; + const syncDictionaryValues = (dictionaryId: string) => { + if (!snapshot || !currentPlatform || !selectedCategoryId) return; + const query = (dictionarySearches[dictionaryId] ?? '').trim(); + if (query.length < 2) { + message.info('请输入至少 2 个字符后再从 Ozon 搜索'); + return; + } + void runCatalogSync( + `dictionary:${dictionaryId}:${query}`, + () => + productEditorApi.syncAttributeDictionary( + snapshot.profile.id, + currentPlatform.id, + dictionaryId, + selectedCategoryId, + query + ), + '字典值已同步', + ).then(() => void loadDictionaryValues(dictionaryId, query)); + }; if (!currentPlatform) return ; - if (loading && !snapshot) return ; - if (!snapshot) return void load()}>重试} />; + if (loading && !snapshot) + return ; + if (!snapshot) + return ( + void load()}>重试} + /> + ); - return
- - - -
商品编辑当前平台:{currentPlatform.name} · 草稿保存不会发布商品
- + const hasSourceCategory = snapshot.profile.category.source === 'source_capture'; + const summary = collectedSummary(collected ?? snapshot.source_material); + + return ( + setDirty(true)} + > +
+ +
+
+ + {snapshot.product.common.source_title || '未命名商品'} + +
+
+ {currentPlatform.name} + {snapshot.profile.target_store.name} + {snapshot.profile.listing_summary.detail} + + {dirty ? '有未保存修改' : '已保存'} + +
+
+
+ {snapshot.warnings.map((warning) => ( + + ))} + +
+
+ + +
+ + + + + + + + + +
+ +
+ + + } + onClick={() => + document + .getElementById('dynamic-attributes') + ?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } + > + 填写更多属性 + + } + /> + + + + + setRichContent(event.target.value)} + /> + +
+ 类目属性 + {!selectedCategoryId ? ( + + ) : !attributeSchema.length ? ( + + 该类目的属性 schema 尚未同步 + + + } + /> + ) : ( + + {(fields, { add, remove }) => ( +
+ {attributeSchema.flatMap((definition) => { + const scopes = definition.is_aspect + ? snapshot.variants.map((variant) => ({ + scope: 'variant' as const, + variantId: variant.id, + suffix: + variant.display_name || '未命名 SKU', + })) + : [ + { + scope: 'common' as const, + variantId: null, + suffix: null, + }, + ]; + return scopes.map( + ({ scope, variantId, suffix }) => { + const field = fields.find((candidate) => { + const draft = + draftAttributes[candidate.name]; + return ( + draft?.scope === scope && + draft?.variant_id === variantId && + draft?.platform_attribute_id === + definition.platform_attribute_id && + draft?.complex_id === + definition.complex_id + ); + }); + const label = suffix + ? `${definition.name} · ${suffix}` + : definition.name; + if (!field) + return ( + + + + ); + const dictionaryOptions = + definition.dictionary_id + ? ( + dictionaryValues[ + definition.dictionary_id + ] ?? [] + ).map((value) => ({ + value: value.value_id, + label: value.value, + })) + : []; + const dictionaryQuery = definition.dictionary_id + ? dictionarySearches[ + definition.dictionary_id + ] ?? '' + : ''; + const valueName = [ + field.name, + 'values', + ] as NamePath; + const input = + definition.value_type === + 'dictionary' ? ( + + + + ) : definition.is_collection ? ( + + + + ); + return ( + + + {input} + + + + ); + } + ); + })} +
+ )} +
+ )} +
+ {snapshot.unmapped_attributes.length > 0 && ( + + `${attribute.original_name}:${sourceValue(attribute.values)}` + ) + .join(';')} + /> + )} +
+ + + {snapshot.variants.length} 个 SKU} + /> + + {(fields, { add, remove }) => { + const addVariant = () => + add({ + id: null, + client_key: crypto.randomUUID(), + copy_from_variant_id: null, + display_name: null, + source_sku: null, + specification_values: [], + purchase_cost_cny: null, + physical_spec: { + length_mm: null, + width_mm: null, + height_mm: null, + net_weight_g: null, + status: null, + }, + package_spec: { + length_mm: null, + width_mm: null, + height_mm: null, + shipping_weight_g: null, + status: null, + }, + offer_id: null, + } satisfies VariantValues); + return ( +
+ {fields.map((field, index) => { + const sourceId = form.getFieldValue([ + 'variants', + field.name, + 'id', + ]) as string | null; + const original = snapshot.variants.find( + (variant) => variant.id === sourceId + ); + const variantValue = form.getFieldValue([ + 'variants', + field.name, + ]) as VariantValues; + const expanded = expandedVariantKeys.has(field.key); + const imageUrls = original + ? variantImageUrls(collectionRecord, original) + : []; + const copyVariant = () => + add({ + ...variantValue, + id: null, + client_key: crypto.randomUUID(), + copy_from_variant_id: original?.id ?? null, + display_name: variantValue.display_name + ? `${variantValue.display_name} 副本` + : 'SKU 副本', + offer_id: null, + }); + const toggle = () => + setExpandedVariantKeys((previous) => { + const next = new Set(previous); + if (next.has(field.key)) next.delete(field.key); + else next.add(field.key); + return next; + }); + return ( + + + + +
+ + {index + 1} + + + {original && + statusTag(original.listing.status)} + + {fields.length > 1 && ( + + remove(field.name) + } + > + + + )} + +
+
+
+ SKU 名称 + + + +
+
+ 主图 +
+ {imageUrls[0] ? ( + SKU 主图 + ) : ( + 暂无主图 + )} +
+
+
+ 货号 + + + +
+
+ 采购价 + + + +
+
+ 销售价 + +
+
+ 净利润 + — +
+ +
+ {expanded && ( +
+
+ + SKU 图片 + + {imageUrls.length ? ( +
+
+ SKU 主图 +
+
+ {imageUrls + .slice(1, 5) + .map( + ( + url, + imageIndex + ) => ( +
+ {`SKU +
+ ) + )} +
+
+ ) : ( +
+ 暂无采集图片 +
+ )} +
+
+ + SKU 规格 + + + + + + + + + + + + + + + {(specFields) => + specFields.length ? ( + <> + {specFields.map( + (specField) => { + const axisKey = + form.getFieldValue( + [ + 'variants', + field.name, + 'specification_values', + specField.name, + 'axis_key', + ] + ) as + | string + | undefined; + const label = + /color|颜色/i.test( + axisKey ?? + '' + ) + ? '商品颜色' + : axisKey || + '商品规格'; + return ( + + + + ); + } + )} + + ) : ( + + + + ) + } + +
+
+ )} +
+ ); + })} +
+ +
+
+ ); + }} +
+
+ + + +
+ + + + + + +
+ + 已上架 SKU 的编辑仅保存 Atlas 草稿,不会自动更新 {currentPlatform.name}{' '} + 商品。 + +
+
+ +
+
+ + + + - - {snapshot.warnings.map((warning) => )} - - - - - - + + + + - - - - - - {snapshot.profile.target_store.name}({snapshot.profile.target_store.currency_code}) - {snapshot.profile.market_code ?? '未配置'} - - - - - - - - - 保存草稿不提交 Ozon}> - - {(fields, { add, remove }) => - {fields.map((field, index) => 1 ? : null}> - - - - - - - - - - )} - - } - - - - - - ; +
+ + ); }; export default ProductEditPage; diff --git a/apps/web/src/services/productEditor.ts b/apps/web/src/services/productEditor.ts index 0a76236..164be04 100644 --- a/apps/web/src/services/productEditor.ts +++ b/apps/web/src/services/productEditor.ts @@ -1,6 +1,10 @@ import type { + ProductAttributeDictionaryValue, + ProductAttributeSchema, + ProductEditorCategoryOption, ProductEditorContext, ProductEditorSnapshot, + Operation, SaveProductEditorDraftRequest, SaveProductEditorDraftResponse, } from '@atlas/contracts'; @@ -8,9 +12,16 @@ import { api } from './api'; export const productEditorApi = { enter: (collectionItemId: string, platformId: string) => - api.post(`/collection-items/${collectionItemId}/editor-contexts`, undefined, { - headers: { 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': crypto.randomUUID() }, - }), + api.post( + `/collection-items/${collectionItemId}/editor-contexts`, + undefined, + { + headers: { + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': crypto.randomUUID(), + }, + } + ), read: (productId: string, platformId: string) => api.get(`/products/${productId}/editor`, { headers: { 'X-Atlas-Platform-Id': platformId }, @@ -19,12 +30,88 @@ export const productEditorApi = { productId: string, platformId: string, revisions: { product: number; profile: number }, - payload: SaveProductEditorDraftRequest, - ) => api.put(`/products/${productId}/editor-draft`, payload, { - headers: { - 'X-Atlas-Platform-Id': platformId, - 'Idempotency-Key': crypto.randomUUID(), - 'If-Match': `"${revisions.product}:${revisions.profile}"`, - }, - }), + payload: SaveProductEditorDraftRequest + ) => + api.put(`/products/${productId}/editor-draft`, payload, { + headers: { + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': crypto.randomUUID(), + 'If-Match': `"${revisions.product}:${revisions.profile}"`, + }, + }), + categoryOptions: (profileId: string, platformId: string, search?: string) => + api.get( + `/product-platform-profiles/${profileId}/category-options`, + { + params: search ? { q: search } : undefined, + headers: { 'X-Atlas-Platform-Id': platformId }, + } + ), + syncCategoryTree: (profileId: string, platformId: string) => + api.post<{ operation: Operation }>( + `/product-platform-profiles/${profileId}/category-syncs`, + undefined, + { + headers: { + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': crypto.randomUUID(), + }, + } + ), + attributeSchema: (profileId: string, platformId: string, categoryId?: string | null) => + api.get( + `/product-platform-profiles/${profileId}/attribute-schema`, + { + params: categoryId ? { category_id: categoryId } : undefined, + headers: { 'X-Atlas-Platform-Id': platformId }, + } + ), + syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) => + api.post<{ operation: Operation }>( + `/product-platform-profiles/${profileId}/attribute-schema-syncs`, + undefined, + { + params: { category_id: categoryId }, + headers: { + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': crypto.randomUUID(), + }, + } + ), + attributeDictionary: ( + profileId: string, + platformId: string, + dictionaryId: string, + search?: string, + categoryId?: string | null + ) => + api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>( + `/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`, + { + params: { + ...(categoryId ? { category_id: categoryId } : {}), + ...(search ? { q: search } : {}), + }, + headers: { 'X-Atlas-Platform-Id': platformId }, + } + ), + syncAttributeDictionary: ( + profileId: string, + platformId: string, + dictionaryId: string, + categoryId: string, + query: string + ) => + api.post<{ operation: Operation }>( + `/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`, + undefined, + { + params: { category_id: categoryId, q: query }, + headers: { + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': crypto.randomUUID(), + }, + } + ), + operation: (operationId: string) => api.get(`/operations/${operationId}`), }; diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index c9f4d9c..b274807 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -13,6 +13,11 @@ import { import { decryptStoreSecret } from './store-secret.crypto.js'; import { MediaUploadCleanup } from './media-upload-cleanup.js'; import { materializeCollectionAssets } from './collection-assets.js'; +import { + markCatalogSyncUnavailable, + syncOzonCatalog, + type CatalogSyncScope, +} from './ozon-catalog-sync.js'; const connection = new Redis(workerConfig.redisUrl, { maxRetriesPerRequest: null, @@ -35,6 +40,18 @@ const objectStorage = new S3Client({ }); const mediaUploadCleanup = new MediaUploadCleanup(database, objectStorage); type StoreVerificationJob = OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } }; +type CatalogSyncJob = OutboxJob & { + payload: { + store_id: string; + secret_id: string; + operation_id: string; + platform_id: string; + scope: CatalogSyncScope; + category_id?: string; + dictionary_id?: string; + query?: string; + }; +}; const worker = new Worker( 'atlas-dispatch', @@ -45,6 +62,12 @@ const worker = new Worker( await cleanupCollection(database, job.data as OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } }); } else if (job.name === 'collection.assets.materialize.requested' || job.name === 'collection.assets.retry.requested') { await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data); + } else if ( + job.name === 'platform_category_tree_sync.requested' || + job.name === 'platform_category_attribute_sync.requested' || + job.name === 'platform_attribute_dictionary_sync.requested' + ) { + await syncOzonCatalog(database, job.data as CatalogSyncJob); } }, { connection, concurrency: 5 }, @@ -59,8 +82,16 @@ async function cleanupCollection(database: Database, job: OutboxJob & { payload: } worker.on('failed', (job, error) => { - if (!job || job.attemptsMade < (job.opts.attempts ?? 1) || job.name !== 'store_credential_verification.requested') return; - void markUnavailable(database, job.data as StoreVerificationJob, error); + if (!job || job.attemptsMade < (job.opts.attempts ?? 1)) return; + if (job.name === 'store_credential_verification.requested') { + void markUnavailable(database, job.data as StoreVerificationJob, error); + } else if ( + job.name === 'platform_category_tree_sync.requested' || + job.name === 'platform_category_attribute_sync.requested' || + job.name === 'platform_attribute_dictionary_sync.requested' + ) { + void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error); + } }); async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) { diff --git a/apps/worker/src/ozon-catalog-sync.ts b/apps/worker/src/ozon-catalog-sync.ts new file mode 100644 index 0000000..6beec35 --- /dev/null +++ b/apps/worker/src/ozon-catalog-sync.ts @@ -0,0 +1,499 @@ +import type { Database } from '@atlas/database'; +import type { OutboxJob } from './outbox-dispatcher.js'; +import { + fetchOzonCategoryAttributes, + fetchOzonCategoryTree, + fetchOzonDictionaryValues, + OzonCredentialError, + OzonRequestError, + type OzonCategoryAttribute, + type OzonCategoryNode, + type OzonDictionaryValue, +} from './ozon-client.js'; +import { decryptStoreSecret } from './store-secret.crypto.js'; +import { workerConfig } from './config.js'; + +export type CatalogSyncScope = 'tree' | 'attributes' | 'dictionary'; +type CatalogSyncJob = OutboxJob & { + payload: { + store_id: string; + secret_id: string; + operation_id: string; + platform_id: string; + scope: CatalogSyncScope; + category_id?: string; + dictionary_id?: string; + query?: string; + }; +}; + +type SyncSource = { + platform_code: string; + platform_client_id: string; + secret_ciphertext: Buffer; + secret_nonce: Buffer; +}; +type CategorySource = { + external_category_id: string; + external_type_id: string | null; +}; + +/** + * Keeps platform metadata global while every credential and operation lookup + * remains inside the requesting workspace. Neither queue payloads nor cache + * rows contain the decrypted API key. + */ +export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) { + const source = await loadSource(database, job); + if (!source) return cancelStaleOperation(database, job); + if (source.platform_code.toUpperCase() !== 'OZON') { + return failOperation( + database, + job, + 'PLATFORM_CATALOG_UNSUPPORTED', + '当前平台暂不支持类目缓存同步', + ); + } + + const apiKey = decryptStoreSecret( + source.secret_ciphertext, + source.secret_nonce, + workerConfig.storeSecretEncryptionKey, + ); + try { + let result: Record; + if (job.payload.scope === 'tree') { + const nodes = await fetchOzonCategoryTree(source.platform_client_id, apiKey); + result = await persistCategoryTree(database, job.payload.platform_id, nodes); + } else { + const category = await requireCategory( + database, + job.payload.platform_id, + job.payload.category_id, + ); + if (!category.external_type_id) { + return failOperation( + database, + job, + 'CATEGORY_TYPE_UNAVAILABLE', + '该类目缺少 Ozon 商品类型,无法同步属性', + ); + } + if (job.payload.scope === 'attributes') { + const attributes = await fetchOzonCategoryAttributes( + source.platform_client_id, + apiKey, + category.external_category_id, + category.external_type_id, + ); + result = await persistAttributes( + database, + job.payload.platform_id, + job.payload.category_id ?? '', + attributes, + ); + } else { + const dictionary = await requireDictionaryAttribute( + database, + job.payload.platform_id, + job.payload.category_id, + job.payload.dictionary_id, + ); + const values = await fetchOzonDictionaryValues( + source.platform_client_id, + apiKey, + { + attributeId: dictionary.platform_attribute_id, + categoryId: category.external_category_id, + typeId: category.external_type_id, + query: job.payload.query, + }, + ); + result = await persistDictionaryValues( + database, + job.payload.platform_id, + job.payload.dictionary_id ?? '', + values, + ); + } + } + await succeedOperation(database, job, result); + } catch (error) { + if (error instanceof OzonCredentialError) { + await failOperation( + database, + job, + error.status === 403 + ? 'OZON_CATALOG_CREDENTIAL_FORBIDDEN' + : 'OZON_CATALOG_CREDENTIAL_UNAUTHORIZED', + error.status === 403 + ? 'Ozon 拒绝读取类目:请检查 API Key 权限' + : 'Ozon 未接受当前门店凭据', + ); + return; + } + if (error instanceof OzonRequestError) { + await failOperation( + database, + job, + 'OZON_CATALOG_REQUEST_REJECTED', + `Ozon 拒绝类目同步请求(HTTP ${error.status})`, + ); + return; + } + throw error; + } +} + +export async function markCatalogSyncUnavailable( + database: Database, + job: CatalogSyncJob, + _error: Error, +) { + await failOperation( + database, + job, + 'OZON_CATALOG_SYNC_UNAVAILABLE', + '暂时无法同步 Ozon 类目数据,请稍后重试', + ); +} + +async function loadSource(database: Database, job: CatalogSyncJob) { + return database.workspaceTransaction(job.workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), + attempt_count = attempt_count + 1 + WHERE id = $1 AND status IN ('queued', 'running')`, + [job.payload.operation_id], + ); + const source = await tx.query( + `SELECT platform.code AS platform_code, store.platform_client_id, + secret.secret_ciphertext, secret.secret_nonce + FROM atlas.operations operation + JOIN atlas.stores store ON store.id = $2 AND store.deleted_at IS NULL + JOIN atlas.platforms platform ON platform.id = store.platform_id AND platform.id = $3 + JOIN atlas.store_secrets secret ON secret.id = $4 AND secret.store_id = store.id + WHERE operation.id = $1 AND operation.kind IN ( + 'platform_category_tree_sync', + 'platform_category_attribute_sync', + 'platform_attribute_dictionary_sync' + ) + AND store.credential_status = 'valid' + AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL + LIMIT 1`, + [ + job.payload.operation_id, + job.payload.store_id, + job.payload.platform_id, + job.payload.secret_id, + ], + ); + return source.rows[0] ?? null; + }); +} + +async function cancelStaleOperation(database: Database, job: CatalogSyncJob) { + await database.workspaceTransaction(job.workspaceId, (tx) => + tx.query( + `UPDATE atlas.operations SET status = 'cancelled', completed_at = now(), + error_code = 'CATALOG_SYNC_SOURCE_REPLACED', + error_message = '门店凭据或发布配置已变化,旧类目同步已取消' + WHERE id = $1 AND status IN ('queued', 'running')`, + [job.payload.operation_id], + ), + ); +} + +async function succeedOperation( + database: Database, + job: CatalogSyncJob, + result: Record, +) { + await database.workspaceTransaction(job.workspaceId, (tx) => + tx.query( + `UPDATE atlas.operations SET status = 'succeeded', completed_at = now(), + result = $2::jsonb, error_code = NULL, error_message = NULL + WHERE id = $1 AND status IN ('queued', 'running')`, + [job.payload.operation_id, JSON.stringify({ scope: job.payload.scope, ...result })], + ), + ); +} + +async function failOperation( + database: Database, + job: CatalogSyncJob, + code: string, + message: string, +) { + await database.workspaceTransaction(job.workspaceId, (tx) => + tx.query( + `UPDATE atlas.operations SET status = 'failed', completed_at = now(), + error_code = $2, error_message = $3 + WHERE id = $1 AND status IN ('queued', 'running')`, + [job.payload.operation_id, code, message], + ), + ); +} + +async function requireCategory( + database: Database, + platformId: string, + categoryId: string | undefined, +) { + if (!categoryId) throw new Error('Catalog sync category is missing'); + const result = await database.query( + `SELECT external_category_id, external_type_id + FROM atlas.platform_product_categories + WHERE id = $1 AND platform_id = $2 AND is_active`, + [categoryId, platformId], + ); + if (!result.rows[0]) throw new Error('Catalog sync category no longer exists'); + return result.rows[0]; +} + +async function requireDictionaryAttribute( + database: Database, + platformId: string, + categoryId: string | undefined, + dictionaryId: string | undefined, +) { + if (!categoryId || !dictionaryId) throw new Error('Catalog dictionary input is missing'); + const result = await database.query<{ platform_attribute_id: string }>( + `SELECT platform_attribute_id + FROM atlas.platform_product_category_attributes + WHERE platform_id = $1 AND platform_product_category_id = $2 AND dictionary_id = $3 + LIMIT 1`, + [platformId, categoryId, dictionaryId], + ); + if (!result.rows[0]) throw new Error('Catalog dictionary is no longer valid for this category'); + return result.rows[0]; +} + +type FlatCategory = { + external_category_id: string; + external_type_id: string; + parent_key: string | null; + name: string; + path: string; + is_active: boolean; + source_payload: OzonCategoryNode; +}; + +async function persistCategoryTree( + database: Database, + platformId: string, + roots: OzonCategoryNode[], +) { + // Ozon 的树偶尔会在不同分支重复返回同一个类目;缓存主键是 + // (category_id, type_id),因此保留最后一个完整路径而不让批量 upsert 冲突。 + const records = [ + ...new Map( + flattenCategories(roots).map((record) => [ + categoryKey(record.external_category_id, record.external_type_id), + record, + ]), + ).values(), + ]; + const syncTime = new Date(); + await database.transaction(async (tx) => { + await tx.query( + `WITH incoming AS ( + SELECT * FROM jsonb_to_recordset($2::jsonb) AS value( + external_category_id text, external_type_id text, parent_key text, + name text, path text, is_active boolean, source_payload jsonb + ) + ) + INSERT INTO atlas.platform_product_categories + (platform_id, external_category_id, external_type_id, name, path, + is_active, source_payload, synced_at) + SELECT $1, external_category_id, external_type_id, name, path, + is_active, source_payload, $3::timestamptz + FROM incoming + ON CONFLICT (platform_id, external_category_id, external_type_id) DO UPDATE + SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active, + source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at, + updated_at = now()`, + [platformId, JSON.stringify(records), syncTime], + ); + await tx.query( + `WITH incoming AS ( + SELECT * FROM jsonb_to_recordset($2::jsonb) AS value( + external_category_id text, external_type_id text, parent_key text, + name text, path text, is_active boolean, source_payload jsonb + ) + ), resolved AS ( + SELECT child.id AS child_id, parent.id AS parent_id + FROM incoming + JOIN atlas.platform_product_categories child + ON child.platform_id = $1 + AND child.external_category_id = incoming.external_category_id + AND child.external_type_id = incoming.external_type_id + LEFT JOIN atlas.platform_product_categories parent + ON parent.platform_id = $1 + AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key + ) + UPDATE atlas.platform_product_categories category + SET parent_id = resolved.parent_id, updated_at = now() + FROM resolved WHERE category.id = resolved.child_id`, + [platformId, JSON.stringify(records)], + ); + await tx.query( + `UPDATE atlas.platform_product_categories SET is_active = false, updated_at = now() + WHERE platform_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`, + [platformId, syncTime], + ); + }); + return { + category_count: records.length, + active_category_count: records.filter((record) => record.is_active).length, + }; +} + +function flattenCategories( + nodes: OzonCategoryNode[], + path: string[] = [], + parentKey: string | null = null, +): FlatCategory[] { + const flattened: FlatCategory[] = []; + for (const node of nodes) { + const categoryId = String(node.description_category_id ?? '').trim(); + if (!categoryId || !node.category_name?.trim()) continue; + const typeId = String(node.type_id ?? 0); + const nextPath = [...path, node.category_name.trim()]; + const key = categoryKey(categoryId, typeId); + flattened.push({ + external_category_id: categoryId, + external_type_id: typeId, + parent_key: parentKey, + name: node.category_name.trim(), + path: nextPath.join(' › '), + is_active: node.disabled !== true && typeId !== '0', + source_payload: node, + }); + flattened.push(...flattenCategories(node.children ?? [], nextPath, key)); + } + return flattened; +} + +function categoryKey(categoryId: string, typeId: string) { + // Ozon category/type IDs are scalar identifiers; a printable separator is + // intentional because PostgreSQL text/jsonb forbids embedded NUL bytes. + return `${categoryId}:${typeId}`; +} + +type CachedAttribute = { + platform_attribute_id: string; + complex_id: string; + name: string; + value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary'; + is_required: boolean; + is_collection: boolean; + max_value_count: number; + dictionary_id: string | null; + is_aspect: boolean; + source_payload: OzonCategoryAttribute; +}; + +async function persistAttributes( + database: Database, + platformId: string, + categoryId: string, + attributes: OzonCategoryAttribute[], +) { + const records = attributes + .map(toCachedAttribute) + .filter((attribute): attribute is CachedAttribute => attribute !== null); + await database.transaction(async (tx) => { + await tx.query( + `DELETE FROM atlas.platform_product_category_attributes + WHERE platform_id = $1 AND platform_product_category_id = $2`, + [platformId, categoryId], + ); + if (!records.length) return; + await tx.query( + `INSERT INTO atlas.platform_product_category_attributes + (platform_id, platform_product_category_id, platform_attribute_id, complex_id, + name, value_type, is_required, is_collection, max_value_count, + dictionary_id, is_aspect, source_payload, synced_at) + SELECT $1, $2, platform_attribute_id, complex_id, name, value_type, + is_required, is_collection, max_value_count, dictionary_id, + is_aspect, source_payload, now() + FROM jsonb_to_recordset($3::jsonb) AS value( + platform_attribute_id text, complex_id text, name text, value_type text, + is_required boolean, is_collection boolean, max_value_count integer, + dictionary_id text, is_aspect boolean, source_payload jsonb + )`, + [platformId, categoryId, JSON.stringify(records)], + ); + }); + return { attribute_count: records.length }; +} + +function toCachedAttribute(value: OzonCategoryAttribute): CachedAttribute | null { + const id = String(value.id ?? '').trim(); + const name = value.name?.trim(); + if (!id || !name) return null; + const dictionaryId = String(value.dictionary_id ?? '').trim(); + const hasDictionary = !!dictionaryId && dictionaryId !== '0'; + const isCollection = value.is_collection === true; + const requestedMax = Number(value.max_value_count ?? (isCollection ? 100 : 1)); + return { + platform_attribute_id: id, + complex_id: String(value.complex_id ?? 0), + name, + value_type: hasDictionary ? 'dictionary' : mapAttributeType(value.type), + is_required: value.is_required === true, + is_collection: isCollection, + max_value_count: isCollection + ? Math.max(1, Math.min(100, Number.isFinite(requestedMax) ? requestedMax : 100)) + : 1, + dictionary_id: hasDictionary ? dictionaryId : null, + is_aspect: value.is_aspect === true, + source_payload: value, + }; +} + +function mapAttributeType(value: string | null | undefined): CachedAttribute['value_type'] { + switch (value?.toLowerCase()) { + case 'integer': + return 'integer'; + case 'decimal': + case 'number': + return 'decimal'; + case 'boolean': + return 'boolean'; + default: + return 'text'; + } +} + +async function persistDictionaryValues( + database: Database, + platformId: string, + dictionaryId: string, + values: OzonDictionaryValue[], +) { + const records = values + .map((value) => ({ + external_value_id: String(value.id ?? '').trim(), + value: value.value?.trim(), + source_payload: value, + })) + .filter((value): value is { external_value_id: string; value: string; source_payload: OzonDictionaryValue } => + !!value.external_value_id && !!value.value, + ); + if (!records.length) return { dictionary_value_count: 0 }; + await database.query( + `INSERT INTO atlas.platform_attribute_dictionary_values + (platform_id, dictionary_id, external_value_id, value, is_active, source_payload, synced_at) + SELECT $1, $2, external_value_id, value, true, source_payload, now() + FROM jsonb_to_recordset($3::jsonb) AS value( + external_value_id text, value text, source_payload jsonb + ) + ON CONFLICT (platform_id, dictionary_id, external_value_id) DO UPDATE + SET value = EXCLUDED.value, is_active = true, source_payload = EXCLUDED.source_payload, + synced_at = EXCLUDED.synced_at, updated_at = now()`, + [platformId, dictionaryId, JSON.stringify(records)], + ); + return { dictionary_value_count: records.length }; +} diff --git a/apps/worker/src/ozon-client.ts b/apps/worker/src/ozon-client.ts index 21f9292..d661212 100644 --- a/apps/worker/src/ozon-client.ts +++ b/apps/worker/src/ozon-client.ts @@ -12,6 +12,34 @@ export class OzonRequestError extends Error { } export class OzonRetryableError extends Error {} +export type OzonCategoryNode = { + description_category_id: number | string; + category_name: string; + type_id?: number | string | null; + type_name?: string | null; + disabled?: boolean; + children?: OzonCategoryNode[]; +}; + +export type OzonCategoryAttribute = { + id: number | string; + name: string; + type?: string | null; + is_collection?: boolean; + is_required?: boolean; + is_aspect?: boolean; + max_value_count?: number | null; + dictionary_id?: number | string | null; + complex_id?: number | string | null; + [key: string]: unknown; +}; + +export type OzonDictionaryValue = { + id: number | string; + value: string; + [key: string]: unknown; +}; + /** * Uses the category-tree read endpoint as the credential probe. It has no write side effect and, * unlike warehouse-list, is verified against the current Ozon account integration. @@ -20,22 +48,80 @@ export async function verifyOzonCredentials( clientId: string, apiKey: string, ): Promise { + await requestOzon(clientId, apiKey, '/v1/description-category/tree', { + language: 'ZH_HANS', + }); +} + +/** 平台目录同步只在 Worker 中使用,密钥仅在此函数调用期间留在内存。 */ +export async function fetchOzonCategoryTree(clientId: string, apiKey: string) { + const body = await requestOzon(clientId, apiKey, '/v1/description-category/tree', { + language: 'ZH_HANS', + }); + return readResultArray(body, '类目树'); +} + +export async function fetchOzonCategoryAttributes( + clientId: string, + apiKey: string, + categoryId: string, + typeId: string, +) { + const body = await requestOzon(clientId, apiKey, '/v1/description-category/attribute', { + description_category_id: numberOrText(categoryId), + type_id: numberOrText(typeId), + language: 'ZH_HANS', + }); + return readResultArray(body, '类目属性'); +} + +export async function fetchOzonDictionaryValues( + clientId: string, + apiKey: string, + input: { + attributeId: string; + categoryId: string; + typeId: string; + query?: string; + }, +) { + const query = input.query?.trim() ?? ''; + const path = query + ? '/v1/description-category/attribute/values/search' + : '/v1/description-category/attribute/values'; + const body = await requestOzon(clientId, apiKey, path, { + attribute_id: numberOrText(input.attributeId), + description_category_id: numberOrText(input.categoryId), + type_id: numberOrText(input.typeId), + language: 'ZH_HANS', + limit: query ? 100 : 1_000, + ...(query ? { value: query } : { last_value_id: 0 }), + }); + return readResultArray(body, '属性字典'); +} + +async function requestOzon( + clientId: string, + apiKey: string, + path: string, + body: Record, +): Promise { let response: Response; try { - response = await fetch('https://api-seller.ozon.ru/v1/description-category/tree', { + response = await fetch(`https://api-seller.ozon.ru${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Client-Id': clientId, 'Api-Key': apiKey, }, - body: JSON.stringify({ language: 'ZH_HANS' }), + body: JSON.stringify(body), signal: AbortSignal.timeout(30_000), }); } catch { throw new OzonRetryableError('Ozon request could not be completed'); } - if (response.ok) return; + if (response.ok) return response.json() as Promise; if (response.status === 401 || response.status === 403) { throw new OzonCredentialError(response.status); } @@ -44,3 +130,18 @@ export async function verifyOzonCredentials( } throw new OzonRequestError(response.status); } + +function readResultArray(body: unknown, resource: string): T[] { + if ( + !body || + typeof body !== 'object' || + !Array.isArray((body as { result?: unknown }).result) + ) { + throw new OzonRetryableError(`Ozon ${resource} 响应格式异常`); + } + return (body as { result: T[] }).result; +} + +function numberOrText(value: string) { + return /^\d+$/.test(value) ? Number(value) : value; +} diff --git a/docs/api/README.md b/docs/api/README.md index bd01183..eb971fe 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -16,7 +16,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中 | [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 部分已实现 | 水印模板、字体、店铺推荐水印,以及水印图片直传、校验和短时预览;图片渲染派生待实现。 | | [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 | | [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 | -| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.1 部分已实现 | 编辑上下文、只读快照和产品/平台文案/SKU 的原子草稿保存;类目/属性、媒体、定价、AI 与首次上架仍待实现。 | +| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.5 部分已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并,以及 Ozon 类目树、动态属性和关键词字典缓存同步;媒体、定价、AI 任务与首次上架仍待实现。 | | [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 | 旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。 diff --git a/docs/api/collection-and-extension.md b/docs/api/collection-and-extension.md index ffabf96..31b1296 100644 --- a/docs/api/collection-and-extension.md +++ b/docs/api/collection-and-extension.md @@ -26,7 +26,9 @@ Headers:扩展 Bearer token 与 `Idempotency-Key` 必填。请求体为已定 请求体不得包含或传入 `workspace_id`、`target_platform_id`、`platform_id`、`store_id`、`api_key`、Cookie、授权头或来源站会话字段。服务端从扩展令牌解析工作空间,并将来源 `platform` 映射为系统已启用的来源平台。 -服务端在一个事务中:检查工作空间采集箱上限 → 按 `workspace + source_platform + source_item_key` 查找活动记录 → 创建记录,或以本次页面快照覆盖已有记录的来源 URL、标题、价格、原始证据、规范化载荷、SKU 草稿与候选素材。采集阶段的图片和视频只保存为 `pending_selection` 候选记录,**不下载媒体、不投递素材 Worker**;用户在编辑流程中选择媒体后,才调用按需转存接口。重复上报不会增加采集箱配额,且会保留原有 `collection_item` 与 `product` ID;旧快照中尚未完成的候选或任务会被取消,新快照使用新的来源快照版本,旧 Worker 不得回写新快照。 +服务端在一个事务中:检查工作空间采集箱上限 → 按 `workspace + source_platform + source_item_key` 查找活动记录 → 创建记录,或以本次页面快照覆盖已有记录的来源 URL、标题、价格、原始证据、规范化载荷与候选素材。采集阶段的图片和视频只保存为 `pending_selection` 候选记录,**不下载媒体、不投递素材 Worker**;用户在编辑流程中选择媒体后,才调用按需转存接口。重复上报不会增加采集箱配额,且会保留原有 `collection_item` 与 `product` ID;旧快照中尚未完成的候选或任务会被取消,新快照使用新的来源快照版本,旧 Worker 不得回写新快照。 + +尚未创建任何 `product_platform_profile` 时,SKU 草稿仍是来源投影:同一 `source_variant_key` 原位更新,新 key 创建,缺失 key 软删除。**一旦进入任一平台编辑器,SKU 草稿及其平台 mapping 不再是可替换来源投影。** 此后的重复采集仍更新来源证据、规范化载荷和候选素材;同 key 复用原 SKU ID 且不覆盖用户编辑字段,新发现的来源 SKU 会为每个已有平台配置创建 `not_listed` mapping,来源未返回的 SKU 不自动删除或恢复。来源差异的人工审查 UI 另由商品编辑模块提供,采集接口不得通过删除 SKU 或级联删除映射来“覆盖”草稿。 成功返回 `201`(新建)或 `200`(复用): diff --git a/docs/api/product-editor-and-publishing.md b/docs/api/product-editor-and-publishing.md index 0c8cacd..47b5f61 100644 --- a/docs/api/product-editor-and-publishing.md +++ b/docs/api/product-editor-and-publishing.md @@ -1,6 +1,6 @@ # 商品编辑与首次上架 API -> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现第 2 节的产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制;规格、类目/属性切换、媒体选择、定价、AI 与发布仍是冻结契约,尚未实现。 +> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制。M3.2 已实现目标店铺候选与切换语义、父/子 SKU 的实物/包装规格继承、规格轴、SKU 复制和当前上架状态读模型。M3.3/M3.3.1 已实现 AI 来源素材的字段级合并、原子保存、复采变化提示与 AI 输入读模型。M3.4 已实现本地类目候选、动态属性 schema/字典、属性草稿与类目切换后的未映射保留。M3.5 已通过 Worker 同步 Ozon 类目树,并按类目/关键词按需同步 schema 和字典缓存;媒体选择、定价、AI 任务与发布仍是冻结契约,尚未实现。 > 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。 @@ -19,6 +19,7 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空 ```json { "data": { + "collection_item_id": "uuid", "collection_item_id": "uuid", "product_id": "uuid", "profile_id": "uuid", "target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" }, "editor_path": "/products/uuid/editor" @@ -41,15 +42,37 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空 "id": "uuid", "revision": 8, "platform_id": "uuid", "market_code": "RU", "fulfillment": "rfbs", "target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" }, "title": "俄语标题", "description": null, "tags": [], - "category": { "id": "uuid", "platform_type_id": "123", "source": "source_capture" }, + "category": { "id": "uuid", "platform_type_id": "123", "source": "source_capture", "name": "收纳箱", "path": "家居 › 收纳 › 收纳箱" }, "listing_summary": { "status": "partially_listed", "detail": "1 个 SKU 已导入" } }, - "variants": [], "attributes": [], "media_candidates": [], "attribute_schema": null, + "variants": [], "attributes": [], + "source_material": { + "title": "用户整理后的来源标题", "description": "用户整理后的来源描述", + "price": { "amount": "99.90", "currency_code": "CNY" }, "brand": "来源品牌", + "attributes": [{ "key": "material", "name": "材质", "value": "棉" }], + "categories": [{ "key": "bags", "name": "箱包" }], "source_snapshot_revision": 3 + }, + "source_material_state": { + "latest": {}, + "overridden_fields": ["title", "description"], + "changed_fields": ["title"], + "base_source_snapshot_revision": 2, + "has_source_updates": true + }, + "ai_input": { + "source_material": {}, "default_physical_spec": {}, "default_package_spec": {}, + "variants": [{ "variant_id": "uuid", "display_name": "黑色 / M", "specification_values": [], + "effective_physical_spec": {}, "effective_package_spec": {} }] + }, "warnings": [] } } ``` -`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)及已选媒体。`attribute_schema` 仅在当前类目已确定时返回。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。 +`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)及已选媒体。动态属性 schema 由第 3 节的独立接口按当前类目读取。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。 + +`source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。 + +`ai_input` 是后续 AI 任务的服务端输入视图:由上述有效 `source_material`、父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格与规格轴组成。 ## 2. 原子保存草稿与 SKU 规则 @@ -59,11 +82,17 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key ```json { + "ai_source_material": { + "title": "用户整理后的来源标题", "description": "用户整理后的来源描述", + "price": { "amount": "99.90", "currency_code": "CNY" }, "brand": "来源品牌", + "attributes": [], "categories": [], "source_snapshot_revision": 3 + }, + "ai_source_overridden_fields": ["title", "description"], "common": { "source_title": "商品名称", "brand": "品牌", "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null, "default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" }, "default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" } }, "profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"], - "platform_product_category_id": "uuid", "platform_type_id": "123", "category_source": "manual" }, + "platform_product_category_id": "uuid", "category_source": "manual" }, "variants": [{ "id": "uuid-or-null", "client_key": "new-row-key", "copy_from_variant_id": null, "display_name": "黑色 / M", "source_sku": null, "specification_values": [{ "axis_key": "color", "value_key": "black", "value_name": "黑色" }], "purchase_cost_cny": "80.00", @@ -78,6 +107,11 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key 语义与校验: +- `ai_source_material` 是客户端当前看到的完整有效素材,`ai_source_overridden_fields` 仅列出用户明确修改的顶层字段。服务端只持久化这些字段的覆盖值,其余字段继续跟随最新采集结果;恢复字段时将其从该数组移除即可。 +- 来源素材与商品/SKU 草稿在同一事务保存,受 `product.revision` 保护。`ai_source_material.source_snapshot_revision` 必须等于服务端当前采集版本;保存期间发生复采时返回 `409 SOURCE_SNAPSHOT_CHANGED`,客户端应重新读取合并结果并让用户确认,不能用旧页面覆盖新来源。 +- 保存来源素材不会修改原始采集证据。`ai_source_overridden_fields` 只允许 `title`、`description`、`price`、`brand`、`attributes`、`categories`,且每项按完整顶层值覆盖,不做数组或对象内部的隐式合并。 +- `profile.platform_product_category_id` 必须是当前平台的活动类目;服务端从类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。`attributes` 是完整属性草稿集合,属性 ID、复杂属性 ID、值类型、单/多值上限和字典值均由当前类目 schema 校验。 +- 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。 - `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。 - 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。 - `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。实物规格只影响尺寸图,不用于物流。 @@ -96,11 +130,27 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": " `GET /api/product-platform-profiles/{profile_id}/attribute-schema` -当前 profile 必须已有类目。返回平台适配后的字段 schema、字典端点、`is_required`、`is_collection`、`max_value_count`、`is_aspect` 和适用 scope。类别未选返回 `422 CATEGORY_REQUIRED`。 +当前 profile 必须已有类目。返回本地同步的字段 schema:`platform_attribute_id`、`complex_id`、名称、`value_type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id` 与 `is_aspect`。可带 `?category_id={uuid}` 预览尚未保存的新类目 schema;该类目仍必须属于当前平台且为活动状态。类别未选返回 `422 CATEGORY_REQUIRED`。 `GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}` -转发并缓存平台字典值;不让前端直接持有 Ozon 密钥。字典缺失、过期或类目/属性不匹配时返回 `422`。 +返回本地同步的字典值 `{ items: [{ value_id, value }], next_cursor }`;可同样传 `category_id` 读取尚未保存的新类目。字典必须属于当前类目 schema,否则返回 `422 ATTRIBUTE_DICTIONARY_INVALID`。首期只读取缓存,不让前端直接持有 Ozon 密钥或直接调用 Ozon。 + +`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}` + +返回当前平台最多 100 个活动类目,包含本地 UUID、平台外部类目/类型 ID、名称与路径。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。 + +`POST /api/product-platform-profiles/{profile_id}/category-syncs` + +请求目标门店的已验证 Seller API 凭据同步 Ozon 类目树,返回 `202` 与 `platform_category_tree_sync` operation。Worker 将树写入全局平台缓存;密钥仅在 Worker 内存中解密,outbox 与响应均不包含密钥。 + +`POST /api/product-platform-profiles/{profile_id}/attribute-schema-syncs?category_id={uuid}` + +为已选活动类目创建 `platform_category_attribute_sync` operation。同步以单类目事务替换 schema;下一次编辑草稿保存会依最新 schema 校验并将不匹配旧值转为未映射草稿。 + +`POST /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}/syncs?category_id={uuid}&q={keyword}` + +按关键词(至少两个字符)创建 `platform_attribute_dictionary_sync` operation,Worker 使用 Ozon 字典搜索接口并 upsert 命中的本地值。不会为打开一个字段而全量下载大字典;前端先读本地缓存,缺少结果时由用户明确选择“从 Ozon 搜索”。 `GET /api/product-platform-profiles/{profile_id}/validation` @@ -140,6 +190,8 @@ Headers:`Idempotency-Key` 必填。Body: 所有 AI 接口都只创建任务并返回 `202` + operation/`ai_task_id`,绝不直接改写草稿或发布数据。 +创建 AI 任务前,前端必须先保存当前编辑草稿。服务端从已持久化的 `ai_input` 组装输入,不接收客户端自行拼装的规格:文案和类目任务可读取全部 SKU 的规格摘要;套图和尺寸图任务只读取目标 SKU 的 `effective_physical_spec` / `effective_package_spec`。因此 AI 使用的是用户在编辑页保存后的包装规格与商品规格,而不是采集载荷中的尺寸猜测值。 + | 接口 | body 核心字段 | 成功结果 | |---|---|---| | `POST /api/product-platform-profiles/{profile_id}/ai/category-suggestions` | 可选 `hint` | 读取采集证据和当前平台类目树,创建类目候选任务。Ozon 来源默认不自动调用,但用户可主动调用。 | diff --git a/docs/product/product-ai-editor.md b/docs/product/product-ai-editor.md index 466fe13..a013a37 100644 --- a/docs/product/product-ai-editor.md +++ b/docs/product/product-ai-editor.md @@ -27,7 +27,7 @@ 上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。 -前端根据平台商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。`is_aspect=true` 默认显示在 SKU 区;采购业务确有需要时,用户可把非 aspect 属性(如容量)改为 SKU 属性。类目变更后,失配属性保留为未映射草稿,不能自动进入发布请求。 +前端根据当前平台、本地同步的商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。类目列表为空时,用户可从编辑页发起一次 Ozon 类目树同步;选中类目但 schema 缺失时,显式同步该类目属性。字典控件先搜索本地缓存,输入至少两个字符仍无结果时,可显式从 Ozon 搜索并缓存命中值。`is_aspect=true` 在编辑器中按 SKU 展示,其余属性按商品展示;保存仍由服务端复核 schema 与字典值。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从服务端类目缓存取得。类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。 ## 3. SKU 卡片和列表 @@ -47,6 +47,10 @@ SKU 名称只用于展示;采集 SKU 用稳定 `source_variant_key` 关联, ## 5. 媒体与 AI +AI 输入由两部分组成:一是用户在右侧“采集的商品信息”中整理并保存的来源标题、描述、品牌、价格、规格参数和来源类目;二是用户在左侧商品与 SKU 表单中保存的实物规格、包装规格和规格轴。右侧实际展示“最新规范化采集值 + 用户明确覆盖字段”的有效结果:没有编辑过的字段随复采更新,编辑过的字段保留用户值,并在来源变化时提供对比、逐项恢复和全部采用最新。原始采集证据不因编辑而改变。 + +点击任何 AI 动作前须先保存草稿,服务端再从当前 revision 组装输入;客户端不得把未保存的尺寸或自行解析的来源 JSON 直接提交给 AI。父商品规格与 SKU 有效规格均以后端读模型为准,SKU 的空覆盖字段按继承规则读取左侧父商品规格。 + SKU 图片编辑弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图和采集详情图。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 15 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。 已选图片悬浮时显示“编辑”菜单,至少包含“添加水印”。点击后在弹窗中选择一个工作空间水印模板,后台创建带水印的派生素材;原图和其他 SKU 的选择不被改写。用户确认后,以派生图替换该 SKU 当前图片位置,并可在同一弹窗撤销为原图。首期不在发布时自动加水印,也不因门店默认水印自动处理图片;后续再支持默认模板的预选、批量应用或自动应用。 diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index 9ac308a..26472d0 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -1,6 +1,6 @@ # Atlas Seller Kit 交接说明 -> 更新:2026-09-07。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md)。 +> 更新:2026-09-10。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md)。 ## 1. 项目定位 @@ -10,6 +10,16 @@ 当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。 +## 0. 交接结论(先看这里) + +当前已经完成的是“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环,以及 M3.0–M3.4 商品领域基础、编辑器核心、AI 来源素材字段级合并、动态属性和缓存同步基础。数据库迁移、工作空间隔离、同商品归属外键、幂等键和 product/profile 双乐观锁已经落地。 + +当前没有完成的是“可直接用于发布”的闭环:候选素材选择/物化、定价确认、AI 任务与候选结果、Ozon 首次发布、在线商品同步仍未开发。真实站点回归也尚未由本项目完成;用户已确认此前提出的首次回归项基本满足采集箱和商品编辑需求,但仍建议在最终发布前保留一轮样本验收。 + +当前最高优先级是类目行为改造:旧代码仍允许用户手动同步 Ozon 类目,最新决定要求改为应用启动后同步一次并写入数据库,编辑器使用树形 Cascader 直接读取缓存;Ozon 采集到的类目还需要按层级路径自动回填,且不能覆盖用户已经保存的类目。该项目前处于代码改造中,未完成端到端验收,详见 [1.27](#127-当前进行中2026-09-10类目树交互改造尚未验收)。 + +接手前请注意:工作区存在未提交的 M3 商品编辑、M3.4/M3.5 缓存、前端样式和迁移改动。不要用 `git reset --hard` 或批量清理未跟踪文件;先阅读 `git status --short`,按文件和迁移编号判断归属。当前中间代码的共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。 + ### 1.1 开发进度(2026-09-02) 今日完成三项工作,全部通过 typecheck、生产构建,并经浏览器或启动实测: @@ -176,6 +186,54 @@ - Web 的采集箱“编辑上架”和手动创建现会创建编辑上下文后进入 `/products/{id}/editor`。正式页可编辑产品基础资料、当前平台文案及 SKU/货号,遇到版本冲突会重新加载最新快照。 - 此批刻意不展示未具备平台 schema 或发布校验的数据:类目/属性切换与未映射属性恢复、规格继承、候选媒体物化/选图、定价确认、AI 和首次 Ozon 导入仍待后续纵向任务。 +### 1.21 开发进度(2026-09-10):复采与编辑草稿 P0 修复 + +- 重复采集不再删除 `product_variants`。未进入编辑器前,SKU 仍可作为最新来源快照投影更新;一旦任一 `product_platform_profile` 存在,复采只更新来源证据/规范化载荷/候选素材,并按稳定 `source_variant_key` 复用既有 SKU ID,不覆盖 Atlas 草稿字段或已建平台 listing。 +- 复采新发现的来源 SKU 会为该商品全部现有平台 profile 建立独立 `not_listed` listing;来源不再返回的 SKU 不会被自动删除或恢复。这样既保留用户编辑和发布审计,也不会以级联删除绕开 SKU 与 profile 的同商品归属约束。 +- 扩展集成测试已覆盖“进入编辑、保存人工 SKU 字段与 offer 后再次采集”的场景,并确认原映射保留、新 SKU 可编辑。完整 API 集成测试通过。 + +### 1.22 开发进度(2026-09-10):M3.2 商品编辑核心 + +- 编辑快照与 `PUT /api/products/{id}/editor-draft` 已覆盖父商品实物/包装规格、SKU 覆盖规格、有效继承规格、规格轴值、每个 SKU 当前平台状态、以及该平台全部活动且凭据有效的目标店铺候选。 +- 切换目标店铺仍通过同一原子草稿保存:仅 `not_listed` / `failed` 等未导入 listing 改为新店铺;`imported` SKU 永远保留其首次导入的店铺映射。规格确认状态为 `confirmed` 时服务端要求相应四项尺寸/重量完整,数值必须大于零。 +- Web 编辑页新增父/子规格编辑、规格轴值、复制 SKU、已导入 SKU 删除影响提示、目标店铺选择、上架状态摘要,以及路由离页与浏览器关闭前的未保存修改确认。类目/动态属性、素材选择、定价、AI 与首次发布仍未开始。 +- 商品编辑集成测试覆盖规格保存/继承、复制 SKU、店铺切换与已导入 SKU 映射保留;Web production build 通过。 + +### 1.23 开发进度(2026-09-10):M3.3 编辑器数据闭环 + +- 新增迁移 `019_product_editor_ai_source_material.sql`,在商品聚合中持久化结构化 `ai_source_material`。未保存时由当前规范化采集载荷初始化;保存后作为用户整理稿保留,后续复采不覆盖。原始 `normalized_payload` / `raw_evidence` 不被编辑器修改。 +- `GET /api/products/{id}/editor` 直接返回 `collection_item_id`、`source_material` 和服务端组装的 `ai_input`,Web 不再扫描采集箱前 100 条记录寻找来源。`ai_input` 同时包含父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格和规格轴。 +- `PUT /api/products/{id}/editor-draft` 将 AI 来源素材与商品、平台文案、SKU 和规格原子保存,并继续使用 product/profile 双 revision 与幂等键。右侧来源标题和描述的修改现可持久化;未来 AI 动作必须先保存草稿,再从服务端读模型创建任务。 +- 商品编辑集成测试覆盖来源载荷初始化、用户素材保存、原始证据不变、复采不覆盖整理稿,以及父/SKU 有效规格进入 AI 输入。 + +### 1.24 开发进度(2026-09-10):M3.3.1 来源素材字段级合并 + +- 新增迁移 `020_product_ai_source_overrides.sql`:将 M3.3 的整份 `ai_source_material` 草稿演进为 `ai_source_material_overrides` 与 `ai_source_material_base_revision`。服务端以最新规范化采集数据为底稿,只叠加用户明确编辑过的顶层字段。 +- 编辑快照新增 `source_material_state`:同时返回最新来源、覆盖字段、复采后发生分歧的字段与基线版本。右侧“采集的商品信息”展示合并后的有效值;未编辑字段随复采自动更新,已编辑字段保留,并支持逐项恢复或全部采用最新。 +- 保存请求新增 `ai_source_overridden_fields`。来源版本必须与服务端当前版本一致;保存过程中发生复采返回 `409 SOURCE_SNAPSHOT_CHANGED`,防止旧页面覆盖新证据。原始 `normalized_payload` / `raw_evidence` 仍保持只读。 +- AI 输入继续由服务端组装:来源部分读取字段级合并后的有效素材;规格部分读取用户在左侧保存的父商品规格、SKU 覆盖值及其继承后的有效值。 + +### 1.25 开发进度(2026-09-10):M3.4 类目与动态属性 + +- 新增迁移 `021_product_category_attribute_schema.sql`,建立平台级的类目属性 schema 与字典值缓存表。缓存记录属性值类型、必填、多值上限、字典标识及 `is_aspect`;它们不是工作空间草稿,商品属性只能引用当前平台/类目的有效 schema。 +- 新增编辑器接口:类目候选、类目属性 schema 与属性字典值。类目 schema/字典接口支持传入未保存的 `category_id` 预览,因此用户选择新类目后不必先保存一次才能填写字段。 +- 编辑器草稿可原子保存 `platform_product_category_id`、类目来源与商品/SKU 属性;服务端派生 `platform_type_id`,复核类型、值数量、字典值与 SKU 归属。类目变更或 schema 下线的旧属性会转入 `product_unmapped_attributes`,不会默默丢失或进入新的发布 payload。 +- Web 编辑页已提供类目选择、商品/SKU 维度动态字段、字典选择、未映射属性告警;上架店铺/主题标签保持标准 40px 控件高度,父商品包装重量与三项包装尺寸宽度分别为 150px / 120px。 + +### 1.26 开发进度(2026-09-10):M3.5 Ozon 类目与属性缓存同步 + +- 已实现类目树、类目属性 schema、字典值缓存的数据表、Worker 同步逻辑、Transactional Outbox 和读 API;缓存属于平台级元数据,不属于某个商品草稿。浏览器、operation 响应与 outbox 事件都不含 API Key。 +- 当前旧实现仍把“同步 Ozon 类目”暴露为商品编辑页操作;这已被最新产品决策否定,不能作为完成状态使用。属性 schema 仍是按类目读取/按需同步的现状,后续需单独确认是否也改为启动预热。 +- 字典不会全量拉取:字段先读取已有缓存;用户输入至少两个字符且本地无匹配时,现有实现可显式发起 Ozon `/attribute/values/search`,Worker 仅 upsert 返回的命中项。同步失败应保留旧缓存和可读错误状态。 + +### 1.27 当前进行中(2026-09-10):类目树交互改造,尚未验收 + +- 最新产品要求:应用启动后为 Ozon 执行一次类目树同步并持久化到 `platform_product_categories`;商品编辑页不再出现“同步/刷新类目”按钮,打开“产品类目”时直接从数据库读取并展示树形层级。 +- 当前代码已开始把类目候选扩展为 `parent_id`、`is_active` 并将前端控件改为 Cascader,但仍需完成启动同步触发、旧手动同步入口清理、树形数据与表单值的最终对接,以及真实页面回归。不要把这部分当作 M3.5 已完成。 +- Ozon 采集器现在保存 BreadcrumbList/可见面包屑的名称与 URL 证据,但 `common.categories` 尚未保存可直接关联缓存节点的稳定 Ozon 类目 ID。需要在编辑上下文创建/读取时按规范化层级路径匹配缓存树,并且只在 profile 尚无用户选择时回填 `platform_product_category_id`,来源标记为 `source_capture`;用户手工选择或已保存类目不能被复采覆盖。 +- 类目树同步应使用一个已验证的 Ozon 店铺凭据作为平台共享缓存来源,任务去重、失败重试和并发启动保护必须明确;不能为每个商品或每次打开编辑器重复请求 Ozon。类目缓存为空时页面应显示“缓存准备中/暂无可用类目”,而不是让用户发起同步。 +- 当前中间代码的 TypeScript 检查需要先构建共享 contracts,再检查 Web/API/Worker;已验证命令为:`tsc -p packages/contracts/tsconfig.json`、随后分别执行各应用 `tsc --noEmit`。启动同步与类目自动回填尚未有集成测试。 + ## 2. 已确认产品决策 | 主题 | 决策 | @@ -236,6 +294,17 @@ User → Workspace → Membership / Role 下一步建议按以下顺序推进: +> 注意:下面的历史清单仍保留用于追溯;当前执行优先级以 [`1.27 当前进行中`](#127-当前进行中2026-09-10类目树交互改造尚未验收) 和本段补充为准。先完成类目启动同步、树形选择与 Ozon 来源类目自动回填,再进入真实采集回归和后续发布能力。 + +当前执行顺序: + +1. 实现 Worker 启动时一次性、幂等预热 Ozon 类目树;移除编辑器手动同步 UI;Cascader 直接读取数据库树。 +2. 在创建/读取商品编辑上下文时,用来源面包屑层级与缓存 `path` 精确匹配 Ozon 类目;仅在 profile 没有用户已选类目时回填,并标记 `source_capture`。 +3. 为启动同步、空缓存、重复启动、失败重试、树形选择和自动回填补 API/Worker 集成测试;重新执行 contracts、API、Worker、Web typecheck、Web build 与真实 8100 页面回归。 +4. 完成动态属性 schema/字典缓存、候选媒体、定价、AI 任务、首次发布和在线商品同步的剩余纵向闭环。 + +### 历史计划(仅供追溯) + 1. 在真实 Ozon 多 SKU 页面加载已构建扩展,确认 MAIN world bridge 未被页面策略阻断、三 SKU 都写入采集测试页、SKU 级画廊关系正确,并在新版规范化载荷确认完整特征与描述;再以任一兄弟 SKU 页面重采集,确认服务端覆盖同一记录。 2. 用允许保存的单 SKU、单规格轴和颜色×尺码页面样本,在 1688、淘宝、天猫逐项验证采集结果;页面状态未暴露组合数据时补站点适配器,但绝不以规格轴猜测 SKU。之后独立实施 Seller 网页四接口本地观察实验,默认关闭、仅保存净化结果且不影响标准采集。 3. 将当前 `/collection-test/{collectionItemId}` 只读检查面替换为正式商品编辑入口:按页头平台创建发布配置、预选同平台默认门店,并接入 SKU/媒体/类目编辑。 diff --git a/docs/requirements/collector-browser-extension.md b/docs/requirements/collector-browser-extension.md index 188239b..0edb019 100644 --- a/docs/requirements/collector-browser-extension.md +++ b/docs/requirements/collector-browser-extension.md @@ -84,7 +84,9 @@ | `failed` | 采集请求或关键记录创建失败;保留可诊断错误。 | | `archived` | 用户不再处理的采集记录。 | -同一工作空间内,以 `source_platform + source_item_key` 识别同一来源商品。重复“采集商品”或“编辑上架”保留已有采集记录和商品草稿 ID,但以本次页面重新采集的来源信息覆盖上一份来源快照:标题、价格、原始证据、规范化载荷、来源 SKU 与候选素材均以最新采集为准。旧素材任务取消,新素材任务必须带新的快照版本,旧 Worker 不得回写最新内容。用户希望保留旧版本或创建副本的需求,留待正式商品编辑模块提供明确操作后再设计。 +同一工作空间内,以 `source_platform + source_item_key` 识别同一来源商品。重复“采集商品”或“编辑上架”保留已有采集记录和商品草稿 ID,并以本次页面重新采集的来源信息覆盖上一份**来源快照**:标题、价格、原始证据、规范化载荷与候选素材均以最新采集为准。旧素材任务取消,新素材任务必须带新的快照版本,旧 Worker 不得回写最新内容。 + +在尚未创建平台发布配置前,SKU 草稿可随最新来源快照更新;进入任一平台编辑器后,SKU 已可能含用户编辑、货号、价格或发布历史,来源刷新不得覆盖、删除或恢复这些 Atlas 草稿。此时同 `source_variant_key` 只复用既有 SKU ID,新来源 SKU 创建新的未上架平台 mapping,来源不再出现的 SKU 保留待用户明确处理。商品编辑模块后续提供来源差异审查/应用操作;采集端不能用删除或级联删除来伪造“覆盖”。 ### 3.1 采集箱与目标店铺的绑定时点 diff --git a/packages/contracts/src/product-editor.ts b/packages/contracts/src/product-editor.ts index 70f9fd6..72946c0 100644 --- a/packages/contracts/src/product-editor.ts +++ b/packages/contracts/src/product-editor.ts @@ -1,5 +1,77 @@ import { z } from 'zod'; +export type ProductAiSourceAttribute = { + key: string; + name: string; + value: string; +}; + +export type ProductAiSourceCategory = { + key: string; + name: string; +}; + +export type ProductAiSourceMaterial = { + title: string | null; + description: string | null; + price: { amount: string; currency_code: string } | null; + brand: string | null; + attributes: ProductAiSourceAttribute[]; + categories: ProductAiSourceCategory[]; + source_snapshot_revision: number; +}; + +export type ProductAiSourceField = + 'title' | 'description' | 'price' | 'brand' | 'attributes' | 'categories'; + +export type ProductAiSourceMaterialState = { + latest: ProductAiSourceMaterial; + overridden_fields: ProductAiSourceField[]; + changed_fields: ProductAiSourceField[]; + base_source_snapshot_revision: number | null; + has_source_updates: boolean; +}; + +export type ProductAiInput = { + source_material: ProductAiSourceMaterial; + default_physical_spec: PhysicalSpec; + default_package_spec: PackageSpec; + variants: Array<{ + variant_id: string; + display_name: string | null; + specification_values: SpecificationValue[]; + effective_physical_spec: PhysicalSpec; + effective_package_spec: PackageSpec; + }>; +}; + +export type ProductEditorCategoryOption = { + id: string; + parent_id: string | null; + external_category_id: string; + external_type_id: string | null; + name: string; + path: string; + is_active: boolean; +}; + +export type ProductAttributeSchema = { + platform_attribute_id: string; + complex_id: string; + name: string; + value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary'; + is_required: boolean; + is_collection: boolean; + max_value_count: number; + dictionary_id: string | null; + is_aspect: boolean; +}; + +export type ProductAttributeDictionaryValue = { + value_id: string; + value: string; +}; + /** * M3.0 editor entry and read-model contracts. Draft mutation, pricing, and * publishing DTOs intentionally stay out of this first editor slice. @@ -13,6 +85,7 @@ export type ProductEditorContext = { }; export type ProductEditorSnapshot = { + collection_item_id: string; product: { id: string; revision: number; @@ -24,6 +97,8 @@ export type ProductEditorSnapshot = { purchase_url: string | null; supply_note: string | null; }; + default_physical_spec: PhysicalSpec; + default_package_spec: PackageSpec; }; profile: { id: string; @@ -32,6 +107,11 @@ export type ProductEditorSnapshot = { market_code: string | null; fulfillment: string; target_store: { id: string; name: string; currency_code: string }; + available_target_stores: Array<{ + id: string; + name: string; + currency_code: string; + }>; title: string | null; description: string | null; tags: string[]; @@ -39,6 +119,12 @@ export type ProductEditorSnapshot = { id: string | null; platform_type_id: string | null; source: string | null; + name: string | null; + path: string | null; + }; + listing_summary: { + status: 'not_listed' | 'partially_listed' | 'listed'; + detail: string; }; }; variants: Array<{ @@ -48,6 +134,11 @@ export type ProductEditorSnapshot = { display_name: string | null; sort_order: number; purchase_cost_cny: string | null; + specification_values: SpecificationValue[]; + physical_spec: VariantPhysicalSpec; + package_spec: VariantPackageSpec; + effective_physical_spec: PhysicalSpec; + effective_package_spec: PackageSpec; listing: { id: string; offer_id: string | null; @@ -80,45 +171,246 @@ export type ProductEditorSnapshot = { reason: string; status: string; }>; + source_material: ProductAiSourceMaterial; + source_material_state: ProductAiSourceMaterialState; + ai_input: ProductAiInput; warnings: Array<{ code: string; message: string }>; }; +export type SpecStatus = 'missing' | 'ambiguous' | 'confirmed'; +export type PhysicalSpec = { + length_mm: string | null; + width_mm: string | null; + height_mm: string | null; + net_weight_g: string | null; + status: SpecStatus; +}; +export type PackageSpec = { + length_mm: string | null; + width_mm: string | null; + height_mm: string | null; + shipping_weight_g: string | null; + status: SpecStatus; +}; +export type VariantPhysicalSpec = Omit & { + status: SpecStatus | null; +}; +export type VariantPackageSpec = Omit & { + status: SpecStatus | null; +}; +export type SpecificationValue = { + axis_key: string; + value_key: string; + value_name: string; +}; + const uuid = z.uuid(); const nullableText = (max: number) => z.string().trim().max(max).nullable(); +const nullableDecimal = z + .string() + .regex(/^\d+(\.\d{1,3})?$/) + .refine((value) => Number(value) > 0, '必须大于 0') + .nullable(); +const specStatus = z.enum(['missing', 'ambiguous', 'confirmed']); +const physicalSpec = z + .object({ + length_mm: nullableDecimal, + width_mm: nullableDecimal, + height_mm: nullableDecimal, + net_weight_g: nullableDecimal, + status: specStatus, + }) + .strict(); +const packageSpec = z + .object({ + length_mm: nullableDecimal, + width_mm: nullableDecimal, + height_mm: nullableDecimal, + shipping_weight_g: nullableDecimal, + status: specStatus, + }) + .strict(); +const variantPhysicalSpec = physicalSpec + .extend({ status: specStatus.nullable() }) + .strict(); +const variantPackageSpec = packageSpec + .extend({ status: specStatus.nullable() }) + .strict(); +const specificationValue = z + .object({ + axis_key: z.string().trim().min(1).max(100), + value_key: z.string().trim().min(1).max(200), + value_name: z.string().trim().min(1).max(300), + }) + .strict(); -/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */ -export const saveProductEditorDraftRequestSchema = z.object({ - common: z.object({ - source_title: nullableText(1_000), - brand: nullableText(300), - model: nullableText(300), - barcode: nullableText(300), - purchase_url: z.url().nullable(), - supply_note: nullableText(4_000), - }).strict(), - profile: z.object({ - target_store_id: uuid, - fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']), +const productAttributeDraft = z + .object({ + scope: z.enum(['common', 'variant']), + variant_id: uuid.nullable(), + platform_attribute_id: z.string().trim().min(1).max(200), + complex_id: z.string().trim().min(1).max(100), + values: z.array(z.string().trim().min(1).max(2_000)).min(1).max(100), + language_code: z.string().trim().min(2).max(20).nullable(), + source: z + .enum(['manual', 'source_capture', 'ai_suggestion']) + .default('manual'), + confirmation_status: z + .enum(['missing', 'ambiguous', 'confirmed']) + .default('confirmed'), + }) + .strict() + .superRefine((attribute, context) => { + if (attribute.scope === 'common' && attribute.variant_id !== null) { + context.addIssue({ + code: 'custom', + path: ['variant_id'], + message: '通用属性不能关联 SKU', + }); + } + if (attribute.scope === 'variant' && attribute.variant_id === null) { + context.addIssue({ + code: 'custom', + path: ['variant_id'], + message: 'SKU 属性必须关联 SKU', + }); + } + }); + +export const productAiSourceMaterialSchema = z + .object({ title: nullableText(1_000), description: nullableText(100_000), - tags: z.array(z.string().trim().min(1).max(100)).max(30), - }).strict(), - variants: z.array(z.object({ - id: uuid.nullable(), - client_key: z.string().trim().min(1).max(100).nullable(), - copy_from_variant_id: uuid.nullable().optional(), - display_name: nullableText(1_000), - source_sku: nullableText(500), - purchase_cost_cny: z.string().regex(/^\d+(\.\d{1,4})?$/).nullable(), - offer_id: nullableText(500), - }).strict().superRefine((variant, context) => { - if (!variant.id && !variant.client_key) { - context.addIssue({ code: 'custom', path: ['client_key'], message: '新 SKU 必须提供 client_key' }); - } - })).min(1).max(100), -}).strict(); + price: z + .object({ + amount: z.string().regex(/^\d+(\.\d{1,4})?$/), + currency_code: z.string().length(3).toUpperCase(), + }) + .strict() + .nullable(), + brand: nullableText(300), + attributes: z + .array( + z + .object({ + key: z.string().trim().min(1).max(200), + name: z.string().trim().min(1).max(300), + value: z.string().max(2_000), + }) + .strict(), + ) + .max(500), + categories: z + .array( + z + .object({ + key: z.string().trim().min(1).max(300), + name: z.string().trim().min(1).max(500), + }) + .strict(), + ) + .max(50), + source_snapshot_revision: z.number().int().positive(), + }) + .strict(); -export type SaveProductEditorDraftRequest = z.infer; +export const productAiSourceFieldSchema = z.enum([ + 'title', + 'description', + 'price', + 'brand', + 'attributes', + 'categories', +]); + +export const productAiSourceMaterialOverridesSchema = + productAiSourceMaterialSchema + .omit({ source_snapshot_revision: true }) + .partial() + .strict(); + +/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */ +export const saveProductEditorDraftRequestSchema = z + .object({ + ai_source_material: productAiSourceMaterialSchema, + ai_source_overridden_fields: z + .array(productAiSourceFieldSchema) + .max(6) + .refine( + (fields) => new Set(fields).size === fields.length, + '覆盖字段不能重复', + ), + common: z + .object({ + source_title: nullableText(1_000), + brand: nullableText(300), + model: nullableText(300), + barcode: nullableText(300), + purchase_url: z.url().nullable(), + supply_note: nullableText(4_000), + default_physical_spec: physicalSpec, + default_package_spec: packageSpec, + }) + .strict(), + profile: z + .object({ + target_store_id: uuid, + fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']), + title: nullableText(1_000), + description: nullableText(100_000), + tags: z.array(z.string().trim().min(1).max(100)).max(30), + platform_product_category_id: uuid.nullable().optional(), + category_source: z + .enum(['source_capture', 'ai_suggestion', 'manual']) + .nullable() + .optional(), + }) + .strict(), + attributes: z.array(productAttributeDraft).max(1_000).optional(), + variants: z + .array( + z + .object({ + id: uuid.nullable(), + client_key: z + .string() + .trim() + .min(1) + .max(100) + .nullable(), + copy_from_variant_id: uuid.nullable().optional(), + display_name: nullableText(1_000), + source_sku: nullableText(500), + specification_values: z + .array(specificationValue) + .max(20), + purchase_cost_cny: z + .string() + .regex(/^\d+(\.\d{1,4})?$/) + .nullable(), + physical_spec: variantPhysicalSpec, + package_spec: variantPackageSpec, + offer_id: nullableText(500), + }) + .strict() + .superRefine((variant, context) => { + if (!variant.id && !variant.client_key) { + context.addIssue({ + code: 'custom', + path: ['client_key'], + message: '新 SKU 必须提供 client_key', + }); + } + }), + ) + .min(1) + .max(100), + }) + .strict(); + +export type SaveProductEditorDraftRequest = z.infer< + typeof saveProductEditorDraftRequestSchema +>; export type SaveProductEditorDraftResponse = { product_revision: number; profile_revision: number; diff --git a/packages/database/migrations/019_product_editor_ai_source_material.sql b/packages/database/migrations/019_product_editor_ai_source_material.sql new file mode 100644 index 0000000..f0c0588 --- /dev/null +++ b/packages/database/migrations/019_product_editor_ai_source_material.sql @@ -0,0 +1,10 @@ +SET search_path TO atlas, public; + +-- M3.3 keeps the user-curated AI source material beside the product aggregate. +-- The original collection normalized_payload/raw_evidence remains immutable +-- evidence; this nullable document is initialized from that evidence on read +-- and becomes durable only after the user saves the editor draft. +ALTER TABLE products + ADD COLUMN ai_source_material JSONB, + ADD CONSTRAINT ck_products_ai_source_material_object + CHECK (ai_source_material IS NULL OR jsonb_typeof(ai_source_material) = 'object'); diff --git a/packages/database/migrations/020_product_ai_source_overrides.sql b/packages/database/migrations/020_product_ai_source_overrides.sql new file mode 100644 index 0000000..4241bc8 --- /dev/null +++ b/packages/database/migrations/020_product_ai_source_overrides.sql @@ -0,0 +1,40 @@ +SET search_path TO atlas, public; + +-- M3.3.1 evolves the full AI source snapshot into field-level overrides. +-- Preserve any document saved between migrations 019 and 020 by treating all +-- of its material fields as explicit overrides based on its recorded source +-- snapshot revision. +ALTER TABLE products + ADD COLUMN ai_source_material_base_revision BIGINT + CHECK (ai_source_material_base_revision IS NULL OR ai_source_material_base_revision > 0); + +-- The migrator owns the table, but products uses FORCE RLS. Temporarily turn +-- it off inside this atomic migration so existing rows from every workspace +-- can be transformed; the table lock prevents application writes in between. +ALTER TABLE products NO FORCE ROW LEVEL SECURITY; +ALTER TABLE products DISABLE ROW LEVEL SECURITY; + +UPDATE products +SET ai_source_material_base_revision = + CASE + WHEN ai_source_material ? 'source_snapshot_revision' + AND (ai_source_material->>'source_snapshot_revision') ~ '^[1-9][0-9]*$' + THEN (ai_source_material->>'source_snapshot_revision')::BIGINT + ELSE NULL + END, + ai_source_material = ai_source_material - 'source_snapshot_revision' +WHERE ai_source_material IS NOT NULL; + +ALTER TABLE products RENAME COLUMN ai_source_material TO ai_source_material_overrides; +ALTER TABLE products + ALTER COLUMN ai_source_material_overrides SET DEFAULT '{}'::jsonb; + +UPDATE products +SET ai_source_material_overrides = '{}'::jsonb +WHERE ai_source_material_overrides IS NULL; + +ALTER TABLE products + ALTER COLUMN ai_source_material_overrides SET NOT NULL; + +ALTER TABLE products ENABLE ROW LEVEL SECURITY; +ALTER TABLE products FORCE ROW LEVEL SECURITY; diff --git a/packages/database/migrations/021_product_category_attribute_schema.sql b/packages/database/migrations/021_product_category_attribute_schema.sql new file mode 100644 index 0000000..dbb0411 --- /dev/null +++ b/packages/database/migrations/021_product_category_attribute_schema.sql @@ -0,0 +1,46 @@ +SET search_path TO atlas, public; + +-- M3.4 stores the platform's cached category attribute contract separately +-- from workspace drafts. Product attributes always reference IDs from this +-- schema; dictionary values are a cache, never free-form client metadata. +CREATE TABLE platform_product_category_attributes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE, + platform_product_category_id UUID NOT NULL, + platform_attribute_id TEXT NOT NULL, + complex_id TEXT NOT NULL DEFAULT '0', + name TEXT NOT NULL, + value_type TEXT NOT NULL CHECK (value_type IN ('text', 'integer', 'decimal', 'boolean', 'dictionary')), + is_required BOOLEAN NOT NULL DEFAULT false, + is_collection BOOLEAN NOT NULL DEFAULT false, + max_value_count INTEGER NOT NULL DEFAULT 1 CHECK (max_value_count BETWEEN 1 AND 100), + dictionary_id TEXT, + is_aspect BOOLEAN NOT NULL DEFAULT false, + source_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (platform_id, platform_product_category_id) + REFERENCES platform_product_categories(platform_id, id) ON DELETE CASCADE, + UNIQUE (platform_id, platform_product_category_id, platform_attribute_id, complex_id), + CHECK ((value_type = 'dictionary') = (dictionary_id IS NOT NULL)), + CHECK (is_collection OR max_value_count = 1) +); +CREATE INDEX ix_platform_product_category_attributes_lookup + ON platform_product_category_attributes (platform_id, platform_product_category_id, is_required DESC, name, id); + +CREATE TABLE platform_attribute_dictionary_values ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE, + dictionary_id TEXT NOT NULL, + external_value_id TEXT NOT NULL, + value TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + source_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (platform_id, dictionary_id, external_value_id) +); +CREATE INDEX ix_platform_attribute_dictionary_values_search + ON platform_attribute_dictionary_values (platform_id, dictionary_id, is_active, value, external_value_id);