diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 540d0de..cc30cd4 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -19,8 +19,8 @@ import { bindEditorFields } from './product-editor.attribute-bindings.js'; import { ProductEditorValidationOperations } from './product-editor.validation.js'; import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js'; import { + capturedOzonCategorySignals, latestProductAiSourceMaterial, - normalizedCategoryPath as normalizeCapturedCategoryPath, productAiSourceMaterial as resolveSourceMaterial, productAiSourceOverrides as selectSourceMaterialOverrides, } from './product-editor.source-material.js'; @@ -200,6 +200,11 @@ export class ProductEditorService { product, initialProfile, ); + const capturedCategoryMatch = await this.capturedCategoryMatch( + transaction, + product, + profile.platform_id, + ); const variants = await transaction.query<{ id: string; source_variant_key: string | null; @@ -460,6 +465,7 @@ export class ProductEditorService { })), source_material: sourceMaterial, source_material_state: sourceMaterialResult.state, + captured_category_match: capturedCategoryMatch, ai_input: { source_material: sourceMaterial, default_physical_spec: defaultPhysicalSpec, @@ -1740,8 +1746,8 @@ export class ProductEditorService { } /** - * Ozon 采集只保存类目面包屑而非缓存 UUID。仅当发布配置尚未保存类目时, - * 才能用精确路径匹配回填;之后的复采绝不能覆盖用户或既有持久化选择。 + * Ozon 公开页仅验证首级面包屑和 Type_0 类型值稳定;二、三级导航不可 + * 用于发布类目判定。只有唯一活动叶子候选才可自动回填,已有选择绝不覆盖。 */ private async applyCapturedCategory( transaction: DatabaseTransaction, @@ -1749,34 +1755,13 @@ export class ProductEditorService { profile: ProfileRow, ): Promise { if (profile.platform_product_category_id) return profile; - const categories = latestProductAiSourceMaterial(product).categories; - const sourcePath = normalizeCapturedCategoryPath(categories); - if (!sourcePath) return profile; - - const sourcePlatform = await transaction.query<{ code: string }>( - `SELECT platform.code - FROM atlas.collection_items item - JOIN atlas.platforms platform ON platform.id = item.source_platform_id - WHERE item.id = $1 AND item.deleted_at IS NULL`, - [product.collection_item_id], + const match = await this.capturedCategoryMatch( + transaction, + product, + profile.platform_id, ); - if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') - return profile; - - const category = await transaction.query<{ - id: string; - external_type_id: string | null; - }>( - `SELECT id, external_type_id - FROM atlas.platform_product_categories - WHERE platform_id = $1 AND is_active - AND lower(regexp_replace(path, '\\s+', ' ', 'g')) = $2 - ORDER BY id - LIMIT 1`, - [profile.platform_id, sourcePath], - ); - const matched = category.rows[0]; - if (!matched) return profile; + if (match.status !== 'matched') return profile; + const matched = match.candidates[0]; const updated = await transaction.query<{ id: string }>( `UPDATE atlas.product_platform_profiles @@ -1792,6 +1777,93 @@ export class ProductEditorService { : profile; } + /** 返回 Ozon 来源信号对应的可发布叶子候选,供自动回填或用户确认。 */ + private async capturedCategoryMatch( + transaction: DatabaseTransaction, + product: ProductRow, + platformId: string, + ): Promise { + const unavailable = { + status: 'unavailable' as const, + root_category_name: null, + type_name: null, + candidates: [], + }; + const sourcePlatform = await transaction.query<{ code: string }>( + `SELECT platform.code + FROM atlas.collection_items item + JOIN atlas.platforms platform ON platform.id = item.source_platform_id + WHERE item.id = $1 AND item.deleted_at IS NULL`, + [product.collection_item_id], + ); + if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') return unavailable; + + const signals = capturedOzonCategorySignals( + latestProductAiSourceMaterial(product), + ); + if (!signals.rootCategoryName || !signals.typeName) { + return { + ...unavailable, + root_category_name: signals.rootCategoryName, + type_name: signals.typeName, + }; + } + const candidates = await transaction.query<{ + id: string; + name: string; + path: string; + external_category_id: string; + external_type_id: string; + }>( + `WITH RECURSIVE ancestors AS ( + SELECT category.id AS candidate_id, category.parent_id AS ancestor_parent_id, + category.name AS ancestor_name + FROM atlas.platform_product_categories category + WHERE category.platform_id = $1 + AND category.is_active + AND category.external_type_id IS NOT NULL + AND category.external_type_id <> '0' + AND NOT EXISTS ( + SELECT 1 FROM atlas.platform_product_categories child + WHERE child.platform_id = category.platform_id + AND child.parent_id = category.id + ) + AND lower(regexp_replace( + coalesce(category.source_payload->>'type_name', category.name), + '\\s+', ' ', 'g' + )) = lower(regexp_replace($2, '\\s+', ' ', 'g')) + UNION ALL + SELECT ancestors.candidate_id, parent.parent_id, parent.name + FROM ancestors + JOIN atlas.platform_product_categories parent + ON parent.id = ancestors.ancestor_parent_id + AND parent.platform_id = $1 + ) + SELECT category.id, category.name, category.path, + category.external_category_id, category.external_type_id + FROM ancestors + JOIN atlas.platform_product_categories category + ON category.id = ancestors.candidate_id + WHERE ancestors.ancestor_parent_id IS NULL + AND lower(regexp_replace(ancestors.ancestor_name, '\\s+', ' ', 'g')) + = lower(regexp_replace($3, '\\s+', ' ', 'g')) + ORDER BY category.path, category.id + LIMIT 20`, + [platformId, signals.typeName, signals.rootCategoryName], + ); + const status = candidates.rows.length === 1 + ? 'matched' + : candidates.rows.length + ? 'ambiguous' + : 'unmatched'; + return { + status, + root_category_name: signals.rootCategoryName, + type_name: signals.typeName, + candidates: candidates.rows, + }; + } + /** 兼容旧客户端的门店类目 UUID,并统一解析成平台类目 UUID。 */ private async resolveSelectedPlatformCategoryId( transaction: DatabaseTransaction, diff --git a/apps/api/src/product-editor/product-editor.source-material.ts b/apps/api/src/product-editor/product-editor.source-material.ts index 782cb10..8948e60 100644 --- a/apps/api/src/product-editor/product-editor.source-material.ts +++ b/apps/api/src/product-editor/product-editor.source-material.ts @@ -134,14 +134,16 @@ function objectValue(value: unknown): Record | undefined { : undefined; } -/** 规范化来源类目面包屑,以便和缓存路径做精确匹配。 */ -export function normalizedCategoryPath( - categories: Array<{ name: string }>, -): string | null { - const names = categories - .map((category) => - category.name.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(), - ) - .filter(Boolean); - return names.length ? names.join(' › ') : null; +/** + * Ozon 公开详情页的后二、三级导航并不稳定;自动类目匹配只使用首级 + * 面包屑和页面结构化特征中的 Type_0 值。该信号缺失时必须交给人工选择。 + */ +export function capturedOzonCategorySignals( + source: ProductAiSourceMaterial, +): { rootCategoryName: string | null; typeName: string | null } { + const rootCategoryName = source.categories[0]?.name?.trim() || null; + const typeName = source.attributes.find((attribute) => + /(?:^|:)type_0$/i.test(attribute.key.trim()), + )?.value.trim() || null; + return { rootCategoryName, typeName }; } diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index 9aaf313..0afc2d4 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -1355,13 +1355,19 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { ); }); - it('M3.5 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => { + it('M3.5 按 Ozon 一级类目和类型回填唯一候选,并返回多候选供确认', async () => { const suffix = randomUUID(); + const root = await seedStoreCategory( + `m35-root-${suffix}`, + '0', + 'M3.5 来源', + 'M3.5 来源', + ); const captured = await seedStoreCategory( `m35-source-${suffix}`, 'source-type', - 'M3.5 采集叶', - 'M3.5 来源 › M3.5 采集叶', + 'M3.5 类型', + 'M3.5 来源 › M3.5 类型', ); const manual = await seedStoreCategory( `m35-manual-${suffix}`, @@ -1369,8 +1375,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { 'M3.5 手动叶', 'M3.5 手动 › M3.5 手动叶', ); - seededCategoryIds.push(captured.id, manual.id); + seededCategoryIds.push(root.id, captured.id, manual.id); await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.platform_product_categories + SET parent_id = $1, source_payload = '{"type_name":"M3.5 类型"}'::jsonb + WHERE id = $2`, + [root.id, captured.id], + ); await tx.query( `UPDATE atlas.collection_items SET source_kind = 'platform', source_platform_id = $1, @@ -1382,7 +1394,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { common: { categories: [ { key: 'source-root', name: ' M3.5 来源 ' }, - { key: 'source-leaf', name: 'M3.5 采集叶' }, + ], + attributes: [ + { key: 'ozon:Type_0', name: '类型', value: 'M3.5 类型' }, ], }, }), @@ -1411,6 +1425,37 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { 'source_capture', ); + const duplicate = await seedStoreCategory( + `m35-duplicate-${suffix}`, + 'duplicate-type', + 'M3.5 类型', + 'M3.5 来源 › 另一分支 › M3.5 类型', + ); + seededCategoryIds.push(duplicate.id); + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.platform_product_categories + SET parent_id = $1, source_payload = '{"type_name":"M3.5 类型"}'::jsonb + WHERE id = $2`, + [root.id, duplicate.id], + ); + await tx.query( + `UPDATE atlas.product_platform_profiles + SET platform_product_category_id = NULL, platform_type_id = NULL, + category_source = NULL + WHERE id = $1`, + [first.profileId], + ); + }); + const ambiguous = await request( + `/api/products/${first.productId}/editor`, + { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }, + ); + assert.equal(ambiguous.response.status, 200); + assert.equal(ambiguous.body.data.profile.category.id, null); + assert.equal(ambiguous.body.data.captured_category_match.status, 'ambiguous'); + assert.equal(ambiguous.body.data.captured_category_match.candidates.length, 2); + await database.workspaceTransaction(workspaceId, (tx) => tx.query( `UPDATE atlas.product_platform_profiles diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 995b791..6c2445b 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -1127,6 +1127,16 @@ const ProductEditPage = () => { categoryPaths={categories.paths} categoryNotFoundContent={categoryNotFoundContent} onCategorySearch={setCategorySearch} + onSelectCategoryCandidate={(categoryId) => { + form.setFieldsValue({ + profile: { + platform_product_category_id: categoryId, + category_source: 'manual', + }, + attributes: [], + }); + setDirty(true); + }} usesVariantPackageWeight={usesVariantPackageWeight} usesVariantPackageDimensions={usesVariantPackageDimensions} onUseVariantPackageWeight={useVariantPackageWeight} diff --git a/apps/web/src/pages/products/components/CollectedProductInfoPanel.css b/apps/web/src/pages/products/components/CollectedProductInfoPanel.css index d6f7e68..777984b 100644 --- a/apps/web/src/pages/products/components/CollectedProductInfoPanel.css +++ b/apps/web/src/pages/products/components/CollectedProductInfoPanel.css @@ -115,6 +115,29 @@ white-space: nowrap; } +/* 来源页层级仅用于展示和诊断,不表示已选中的可发布类目。 */ +.collected-category-breadcrumb { + display: flex; + flex-wrap: wrap; + gap: 2px 0; + padding: 9px 10px; + border: 1px solid #ece9f1; + border-radius: 8px; + background: #fbfaff; + color: #352f43; + font-size: 12px; + line-height: 1.55; +} +.collected-category-breadcrumb .ant-breadcrumb-link { + overflow-wrap: anywhere; +} +.collected-category-hint.ant-typography { + display: block; + margin-top: 6px; + font-size: 12px; + line-height: 1.5; +} + /* 规格 / 参数列表 */ .collected-attributes { border: 1px solid #ece9f1; diff --git a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx index 7489b88..6d4b3df 100644 --- a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx +++ b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx @@ -1,5 +1,16 @@ import { useEffect, useState } from 'react'; -import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd'; +import { + Alert, + Breadcrumb, + Button, + Card, + Collapse, + Image, + Input, + Space, + Tag, + Typography, +} from 'antd'; import type { CollectionItemInspection, ProductAiSourceField, @@ -56,6 +67,8 @@ export function CollectedProductInfoPanel({ onAcceptLatest, }: Props) { const sourceDescription = collected?.description ?? ''; + // 类目自动回填始终基于最新采集快照,不能被 AI 输入素材的用户覆盖影响。 + const capturedCategories = snapshot.source_material_state.latest.categories; const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription); useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]); // 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在 @@ -185,6 +198,28 @@ export function CollectedProductInfoPanel({ +
+ 来源类目面包屑 + {capturedCategories.length ? ( + <> + ({ + key: `${category.key}:${index}`, + title: category.name, + }))} + /> + + 自动匹配仅使用一级类目与商品类型;后续层级仅作为来源参考。 + + + ) : ( + + 本次采集未取得来源类目。 + + )} +
规格 / 参数 {collected?.attributes.length ? ( diff --git a/apps/web/src/pages/products/components/ProductAttributeSection.tsx b/apps/web/src/pages/products/components/ProductAttributeSection.tsx index 1ca12ee..fbfc49f 100644 --- a/apps/web/src/pages/products/components/ProductAttributeSection.tsx +++ b/apps/web/src/pages/products/components/ProductAttributeSection.tsx @@ -108,7 +108,7 @@ export function ProductAttributeSection({
+ +
+
+ +
+
+
+
+ 全部 + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+ +
+
+
+
+
+
+
+ +
+
•
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
广告
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
    +
  1. 儿童用品 + + + + +
  2. +
  3. 玩具和游戏 + + + +
  4. +
  5. 人物和配件 + +
  6. +
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
 小雕塑,1 个 #1
+
+
+
+
+
+
+
+
+
+ 新品
+
+
+
+
+
+
+
+
相似商品 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ 小雕塑,1 个 +

+
+
+
+
+
+
+
+
+
颜色: 卡其色
+ +
+
+
+
+
关于商品 +
+
+
前往描述 +
+
+ + +
+
+
+
+
+
类型 +
+
小雕塑 +
+
+
+
颜色 +
+
卡其色 +
+
+
+
材料 +
+
ABS塑料, + 聚氯乙烯 +
+
+
+
数字数目 +
+
1
+
+
+
原产国 +
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
40,97 ¥ + 80,19 ¥ + +
+
与其他银行 +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 小雕塑,1 个 + +
+
+
+
+
+
+
+ + + + + + + + + +
+
+
+
+
+
+
+
+
+
Панда Маркет +
+ +
去商店
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file