feat(edit|extension): 编辑页类目采集开发
This commit is contained in:
@@ -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<ProfileRow> {
|
||||
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<ProductEditorSnapshot['captured_category_match']> {
|
||||
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,
|
||||
|
||||
@@ -134,14 +134,16 @@ function objectValue(value: unknown): Record<string, unknown> | 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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user