feat(edit|extension): 编辑页类目采集开发

This commit is contained in:
R524809
2026-09-22 13:47:24 +08:00
parent 2b06ef0692
commit 95a2a37f1b
12 changed files with 30302 additions and 51 deletions
@@ -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 };
}
+50 -5
View File
@@ -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