feat(edit): 编辑页类目树逻辑开发

This commit is contained in:
2026-09-10 22:26:52 +08:00
parent 946af649dd
commit 05a00d46b9
13 changed files with 628 additions and 323 deletions
@@ -69,6 +69,7 @@ export class ProductEditorController {
async categoryOptions(
@Param('profileId') profileId: string,
@Query('q') query: string | undefined,
@Query('store_id') storeId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
@@ -78,34 +79,16 @@ export class ProductEditorController {
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
query,
optionalUuid('store_id', storeId),
),
};
}
@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,
@Query('store_id') storeId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
@@ -115,6 +98,7 @@ export class ProductEditorController {
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
optionalUuid('category_id', categoryId),
optionalUuid('store_id', storeId),
),
};
}
@@ -149,6 +133,7 @@ export class ProductEditorController {
@Param('dictionaryId') dictionaryId: string,
@Query('q') query: string | undefined,
@Query('category_id') categoryId: string | undefined,
@Query('store_id') storeId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
@@ -162,6 +147,7 @@ export class ProductEditorController {
dictionaryId,
query,
optionalUuid('category_id', categoryId),
optionalUuid('store_id', storeId),
),
};
}
@@ -50,7 +50,7 @@ type ProfileRow = {
title: string | null;
description: string | null;
tags: unknown;
platform_product_category_id: string | null;
store_product_category_id: string | null;
platform_type_id: string | null;
category_source: string | null;
category_name: string | null;
@@ -110,13 +110,18 @@ export class ProductEditorService {
product.id,
existing,
);
const profile = await this.applyCapturedCategory(
transaction,
product,
existing,
);
return {
status: 200,
body: {
data: this.toContext(
collectionItemId,
product.id,
existing,
profile,
),
},
};
@@ -145,13 +150,18 @@ export class ProductEditorService {
if (!profile)
throw new Error('商品编辑上下文创建后无法读取发布配置');
await this.ensureListings(transaction, product.id, profile);
const resolvedProfile = await this.applyCapturedCategory(
transaction,
product,
profile,
);
return {
status: created.rows[0] ? 201 : 200,
body: {
data: this.toContext(
collectionItemId,
product.id,
profile,
resolvedProfile,
),
},
};
@@ -172,17 +182,22 @@ export class ProductEditorService {
transaction,
productId,
);
const profile = await this.findProfile(
const initialProfile = await this.findProfile(
transaction,
productId,
platformId,
);
if (!profile)
if (!initialProfile)
throw new ApiError(
409,
'EDITOR_CONTEXT_REQUIRED',
'请先从采集箱进入商品编辑器',
);
const profile = await this.applyCapturedCategory(
transaction,
product,
initialProfile,
);
const variants = await transaction.query<{
id: string;
source_variant_key: string | null;
@@ -359,7 +374,7 @@ export class ProductEditorService {
)
: [],
category: {
id: profile.platform_product_category_id,
id: profile.store_product_category_id,
platform_type_id: profile.platform_type_id,
source: profile.category_source,
name: profile.category_name,
@@ -427,6 +442,7 @@ export class ProductEditorService {
profileId: string,
platformId: string,
search: string | undefined,
storeId?: string,
) {
return this.database.workspaceTransaction(
workspaceId,
@@ -437,6 +453,9 @@ export class ProductEditorService {
platformId,
);
const needle = search?.trim() ?? '';
const selectedStoreId = storeId ?? profile.target_store_id;
if (storeId)
await this.requireEditableStore(transaction, storeId, platformId);
const categories = await transaction.query<{
id: string;
parent_id: string | null;
@@ -447,11 +466,11 @@ export class ProductEditorService {
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
FROM atlas.store_product_categories
WHERE store_id = $1
ORDER BY path, name, id
LIMIT 10000`,
[profile.platform_id],
[selectedStoreId],
);
return categories.rows;
},
@@ -463,6 +482,7 @@ export class ProductEditorService {
profileId: string,
platformId: string,
categoryId?: string,
storeId?: string,
) {
return this.database.workspaceTransaction(
workspaceId,
@@ -473,7 +493,10 @@ export class ProductEditorService {
platformId,
);
const selectedCategoryId =
categoryId ?? profile.platform_product_category_id;
categoryId ?? profile.store_product_category_id;
const selectedStoreId = storeId ?? profile.target_store_id;
if (storeId)
await this.requireEditableStore(transaction, storeId, platformId);
if (!selectedCategoryId)
throw new ApiError(
422,
@@ -483,12 +506,12 @@ export class ProductEditorService {
if (categoryId)
await this.requireActiveCategory(
transaction,
profile.platform_id,
selectedStoreId,
categoryId,
);
return this.attributeSchemaForCategory(
transaction,
profile.platform_id,
selectedStoreId,
selectedCategoryId,
);
},
@@ -502,6 +525,7 @@ export class ProductEditorService {
dictionaryId: string,
search: string | undefined,
categoryId?: string,
storeId?: string,
) {
return this.database.workspaceTransaction(
workspaceId,
@@ -512,7 +536,10 @@ export class ProductEditorService {
platformId,
);
const selectedCategoryId =
categoryId ?? profile.platform_product_category_id;
categoryId ?? profile.store_product_category_id;
const selectedStoreId = storeId ?? profile.target_store_id;
if (storeId)
await this.requireEditableStore(transaction, storeId, platformId);
if (!selectedCategoryId)
throw new ApiError(
422,
@@ -522,18 +549,18 @@ export class ProductEditorService {
if (categoryId)
await this.requireActiveCategory(
transaction,
profile.platform_id,
selectedStoreId,
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
FROM atlas.store_product_category_attributes
WHERE store_id = $1 AND store_product_category_id = $2
AND dictionary_id = $3
LIMIT 1`,
[profile.platform_id, selectedCategoryId, dictionaryId],
[selectedStoreId, selectedCategoryId, dictionaryId],
);
if (!allowed.rows[0])
throw new ApiError(
@@ -547,40 +574,19 @@ export class ProductEditorService {
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 || '%')
FROM atlas.store_attribute_dictionary_values
WHERE store_id = $1 AND store_product_category_id = $2
AND dictionary_id = $3 AND is_active
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
ORDER BY value, external_value_id
LIMIT 100`,
[profile.platform_id, dictionaryId, needle],
[selectedStoreId, selectedCategoryId, 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,
@@ -682,18 +688,18 @@ export class ProductEditorService {
platformId,
);
const selectedCategoryId =
input.profile.platform_product_category_id === undefined
? profile.platform_product_category_id
: input.profile.platform_product_category_id;
input.profile.store_product_category_id === undefined
? profile.store_product_category_id
: input.profile.store_product_category_id;
const selectedCategory = selectedCategoryId
? await this.requireActiveCategory(
transaction,
platformId,
input.profile.target_store_id,
selectedCategoryId,
)
: null;
const categoryChanged =
selectedCategoryId !== profile.platform_product_category_id;
selectedCategoryId !== profile.store_product_category_id;
const categorySource = selectedCategory
? categoryChanged
? 'manual'
@@ -758,7 +764,7 @@ export class ProductEditorService {
}>(
`UPDATE atlas.product_platform_profiles
SET target_store_id = $1, fulfillment = $2, title = $3, description = $4,
tags = $5::jsonb, platform_product_category_id = $6,
tags = $5::jsonb, store_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
@@ -993,6 +999,7 @@ export class ProductEditorService {
productId,
profile,
platformId,
targetStoreId: input.profile.target_store_id,
categoryId: selectedCategory?.id ?? null,
attributes: input.attributes,
retainedVariantIds: retainedIds,
@@ -1044,6 +1051,7 @@ export class ProductEditorService {
productId: string;
profile: ProfileRow;
platformId: string;
targetStoreId: string;
categoryId: string | null;
attributes: SaveProductEditorDraftRequest['attributes'];
retainedVariantIds: string[];
@@ -1065,15 +1073,15 @@ export class ProductEditorService {
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
LEFT JOIN atlas.store_product_category_attributes schema
ON schema.store_id = $1
AND schema.store_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.target_store_id,
input.profile.store_product_category_id,
input.profile.id,
],
);
@@ -1088,7 +1096,7 @@ export class ProductEditorService {
}
const schema = await this.attributeSchemaForCategory(
transaction,
input.platformId,
input.profile.target_store_id,
input.categoryId,
);
const schemaByKey = new Map(
@@ -1183,7 +1191,8 @@ export class ProductEditorService {
}
await this.assertAttributeValues(
transaction,
input.platformId,
input.targetStoreId,
input.categoryId,
definition,
attribute.values,
);
@@ -1213,7 +1222,7 @@ export class ProductEditorService {
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_store_product_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')`,
@@ -1222,7 +1231,7 @@ export class ProductEditorService {
input.profile.id,
input.platformId,
attribute.product_variant_id,
input.profile.platform_product_category_id,
input.profile.store_product_category_id,
attribute.platform_attribute_id,
attribute.complex_id,
attribute.original_name,
@@ -1261,7 +1270,8 @@ export class ProductEditorService {
private async assertAttributeValues(
transaction: DatabaseTransaction,
platformId: string,
storeId: string,
categoryId: string,
definition: Awaited<
ReturnType<ProductEditorService['attributeSchemaForCategory']>
>[number],
@@ -1307,10 +1317,11 @@ export class ProductEditorService {
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],
FROM atlas.store_attribute_dictionary_values
WHERE store_id = $1 AND store_product_category_id = $2
AND dictionary_id = $3 AND is_active
AND external_value_id = ANY($4::text[])`,
[storeId, categoryId, definition.dictionary_id, values],
);
if (
new Set(dictionary.rows.map((item) => item.value_id)).size !==
@@ -1325,7 +1336,7 @@ export class ProductEditorService {
private async attributeSchemaForCategory(
transaction: DatabaseTransaction,
platformId: string,
storeId: string,
categoryId: string,
) {
const result = await transaction.query<{
@@ -1342,10 +1353,10 @@ export class ProductEditorService {
}>(
`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
FROM atlas.store_product_category_attributes
WHERE store_id = $1 AND store_product_category_id = $2
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
[platformId, categoryId],
[storeId, categoryId],
);
return result.rows;
}
@@ -1356,7 +1367,7 @@ export class ProductEditorService {
profileId: string;
platformId: string;
key: string;
scope: 'tree' | 'attributes' | 'dictionary';
scope: 'attributes' | 'dictionary';
path: string;
categoryId?: string;
dictionaryId?: string;
@@ -1382,26 +1393,24 @@ export class ProductEditorService {
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.categoryId)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'请先选择产品类目',
);
}
await this.requireActiveCategory(
transaction,
profile.target_store_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
`SELECT id FROM atlas.store_product_category_attributes
WHERE store_id = $1 AND store_product_category_id = $2
AND dictionary_id = $3 LIMIT 1`,
[
profile.platform_id,
profile.target_store_id,
input.categoryId,
input.dictionaryId,
],
@@ -1443,14 +1452,12 @@ export class ProductEditorService {
'当前平台暂不支持类目缓存同步',
);
const kinds = {
tree: 'platform_category_tree_sync',
attributes: 'platform_category_attribute_sync',
dictionary: 'platform_attribute_dictionary_sync',
attributes: 'store_category_attribute_sync',
dictionary: 'store_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',
attributes: 'store_category_attribute_sync.requested',
dictionary: 'store_attribute_dictionary_sync.requested',
} as const;
const created = await transaction.query<OperationRow>(
`INSERT INTO atlas.operations
@@ -1489,9 +1496,62 @@ export class ProductEditorService {
);
}
/**
* Ozon capture records breadcrumb names rather than a cache UUID. Resolve
* that evidence only while a profile has no saved category; a later
* recapture must never replace a user or previously persisted selection.
*/
private async applyCapturedCategory(
transaction: DatabaseTransaction,
product: ProductRow,
profile: ProfileRow,
): Promise<ProfileRow> {
if (profile.store_product_category_id) return profile;
const categories = latestProductAiSourceMaterial(product).categories;
const sourcePath = normalizedCategoryPath(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],
);
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.store_product_categories
WHERE store_id = $1 AND is_active
AND lower(regexp_replace(path, '\\s+', ' ', 'g')) = $2
ORDER BY id
LIMIT 1`,
[profile.target_store_id, sourcePath],
);
const matched = category.rows[0];
if (!matched) return profile;
const updated = await transaction.query<{ id: string }>(
`UPDATE atlas.product_platform_profiles
SET store_product_category_id = $1, platform_type_id = $2,
category_source = 'source_capture', revision = revision + 1,
updated_at = now()
WHERE id = $3 AND store_product_category_id IS NULL
RETURNING id`,
[matched.id, matched.external_type_id, profile.id],
);
return updated.rows[0]
? this.requireProfile(transaction, profile.id)
: profile;
}
private async requireActiveCategory(
transaction: DatabaseTransaction,
platformId: string,
storeId: string,
categoryId: string,
) {
const result = await transaction.query<{
@@ -1499,15 +1559,15 @@ export class ProductEditorService {
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],
FROM atlas.store_product_categories
WHERE id = $1 AND store_id = $2 AND is_active`,
[categoryId, storeId],
);
if (!result.rows[0])
throw new ApiError(
422,
'CATEGORY_INVALID',
'类目不存在、已停用或不属于当前平台',
'类目不存在、已停用或不属于当前店铺',
);
return result.rows[0];
}
@@ -1849,15 +1909,15 @@ 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.fulfillment, p.title, p.description, p.tags, p.store_product_category_id,
p.platform_type_id, p.category_source, category.name AS category_name,
category.path AS category_path, p.target_store_id, s.name AS store_name,
s.currency_code
FROM atlas.product_platform_profiles p
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`;
LEFT JOIN atlas.store_product_categories category
ON category.id = p.store_product_category_id
AND category.store_id = p.target_store_id`;
type VariantSpecRow = {
specification_values: unknown;
@@ -2077,6 +2137,21 @@ function latestProductAiSourceMaterial(
};
}
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;
}
function objectValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
+137 -86
View File
@@ -49,6 +49,31 @@ async function createCollectionItem(title: string) {
};
}
async function seedStoreCategory(
externalCategoryId: string,
externalTypeId: string,
name: string,
path: string,
) {
return 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],
);
const storeId = profile.rows[0]?.target_store_id;
if (!storeId) throw new Error('test profile store is missing');
const category = await tx.query<{ id: string }>(
`INSERT INTO atlas.store_product_categories
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
RETURNING id`,
[storeId, platformId, externalCategoryId, externalTypeId, name, path],
);
return { id: category.rows[0].id, storeId };
});
}
describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
beforeAll(async () => {
Object.assign(process.env, {
@@ -117,6 +142,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.store_product_categories WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.products WHERE workspace_id = $1',
[workspaceId],
@@ -137,19 +166,6 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
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();
});
@@ -834,44 +850,33 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
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,
const categoryA = await seedStoreCategory(`m34-a-${randomUUID()}`, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱');
const categoryB = await seedStoreCategory(`m34-b-${randomUUID()}`, 'type-b', 'M3.4 背包', '箱包 › 背包 › M3.4 背包');
seededCategoryIds.push(categoryA.id, categoryB.id);
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
`INSERT INTO atlas.store_product_category_attributes
(workspace_id, store_id, platform_id, store_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)`,
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'material', '材质', 'dictionary', true, false, 1, $5, false),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'color', '颜色', 'dictionary', true, false, 1, $6, true),
(current_setting('app.workspace_id')::uuid, $1, $2, $4, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
[
categoryA.storeId,
platformId,
categoryA.rows[0].id,
categoryB.rows[0].id,
categoryA.id,
categoryB.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],
);
));
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
`INSERT INTO atlas.store_attribute_dictionary_values
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, 'cotton', '棉'),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, $5, 'black', '黑色')`,
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId, colorDictionaryId],
));
const categories = await request(
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
@@ -879,7 +884,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
assert.equal(categories.response.status, 200);
assert.ok(
categories.body.data.some(
(item: { id: string }) => item.id === categoryA.rows[0].id,
(item: { id: string }) => item.id === categoryA.id,
),
);
const before = await request(
@@ -917,7 +922,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
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,
store_product_category_id: categoryA.id,
category_source: 'manual',
},
attributes: [
@@ -958,7 +963,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
);
assert.equal(saved.response.status, 200);
const schema = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-schema?category_id=${categoryA.rows[0].id}`,
`/api/product-platform-profiles/${first.profileId}/attribute-schema?category_id=${categoryA.id}`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(schema.response.status, 200);
@@ -970,7 +975,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
true,
);
const dictionary = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.rows[0].id}`,
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.deepEqual(dictionary.body.data.items, [
@@ -984,7 +989,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
);
assert.equal(
afterSave.body.data.profile.category.id,
categoryA.rows[0].id,
categoryA.id,
);
assert.equal(afterSave.body.data.profile.category.name, 'M3.4 收纳箱');
assert.equal(afterSave.body.data.attributes.length, 2);
@@ -996,7 +1001,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
afterSave.body.data.source_material_state.overridden_fields,
profile: {
...draft.profile,
platform_product_category_id: categoryB.rows[0].id,
store_product_category_id: categoryB.id,
},
attributes: afterSave.body.data.attributes.map(
(attribute: any) => ({
@@ -1045,24 +1050,83 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
);
});
it('M3.5 为类目树、类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => {
it('M3.5 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => {
const suffix = randomUUID();
const captured = await seedStoreCategory(`m35-source-${suffix}`, 'source-type', 'M3.5 采集叶', 'M3.5 来源 › M3.5 采集叶');
const manual = await seedStoreCategory(`m35-manual-${suffix}`, 'manual-type', 'M3.5 手动叶', 'M3.5 手动 › M3.5 手动叶');
seededCategoryIds.push(captured.id, manual.id);
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`UPDATE atlas.collection_items
SET source_platform_id = $1,
normalized_payload = $2::jsonb
WHERE id = $3`,
[
platformId,
JSON.stringify({
common: {
categories: [
{ key: 'source-root', name: ' M3.5 来源 ' },
{ key: 'source-leaf', name: 'M3.5 采集叶' },
],
},
}),
first.collectionItemId,
],
);
await tx.query(
`UPDATE atlas.product_platform_profiles
SET store_product_category_id = NULL, platform_type_id = NULL,
category_source = NULL
WHERE id = $1`,
[first.profileId],
);
});
const resolved = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(resolved.response.status, 200);
assert.equal(
resolved.body.data.profile.category.id,
captured.id,
);
assert.equal(
resolved.body.data.profile.category.source,
'source_capture',
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`UPDATE atlas.product_platform_profiles
SET store_product_category_id = $1, platform_type_id = 'manual-type',
category_source = 'manual'
WHERE id = $2`,
[manual.id, first.profileId],
),
);
const preserved = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(preserved.response.status, 200);
assert.equal(
preserved.body.data.profile.category.id,
manual.id,
);
assert.equal(preserved.body.data.profile.category.source, 'manual');
});
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);
const category = await seedStoreCategory(`m35-category-${randomUUID()}`, 'm35-type', 'M3.5 测试类目', '测试 › M3.5 测试类目');
seededCategoryIds.push(category.id);
seededDictionaryIds.push(dictionaryId);
await database.query(
`INSERT INTO atlas.platform_product_category_attributes
(platform_id, platform_product_category_id, platform_attribute_id, name,
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
`INSERT INTO atlas.store_product_category_attributes
(workspace_id, store_id, platform_id, store_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],
);
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $4, false)`,
[category.storeId, platformId, category.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',
@@ -1082,20 +1146,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
],
);
});
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}`,
`/api/product-platform-profiles/${first.profileId}/attribute-schema-syncs?category_id=${category.id}`,
{
method: 'POST',
headers: headers({
@@ -1105,9 +1157,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
},
);
assert.equal(schema.response.status, 202);
assert.equal(schema.body.data.operation.kind, 'platform_category_attribute_sync');
assert.equal(schema.body.data.operation.kind, 'store_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`,
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.id}&q=%E9%BB%91%E8%89%B2`,
{
method: 'POST',
headers: headers({
@@ -1117,21 +1169,20 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
},
);
assert.equal(dictionary.response.status, 202);
assert.equal(dictionary.body.data.operation.kind, 'platform_attribute_dictionary_sync');
assert.equal(dictionary.body.data.operation.kind, 'store_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'
WHERE aggregate_id = $1 AND event_type LIKE 'store_%_sync.requested'
ORDER BY created_at`,
[first.profileId],
);
assert.equal(events.rows.length, 3);
assert.equal(events.rows.length, 2);
assert.deepEqual(
events.rows.map((event) => event.event_type),
[
'platform_category_tree_sync.requested',
'platform_category_attribute_sync.requested',
'platform_attribute_dictionary_sync.requested',
'store_category_attribute_sync.requested',
'store_attribute_dictionary_sync.requested',
],
);
assert.ok(