feat(edit): 编辑页类目树逻辑开发
This commit is contained in:
@@ -69,6 +69,7 @@ export class ProductEditorController {
|
|||||||
async categoryOptions(
|
async categoryOptions(
|
||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@Query('q') query: string | undefined,
|
@Query('q') query: string | undefined,
|
||||||
|
@Query('store_id') storeId: string | undefined,
|
||||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
@CurrentWorkspace() workspace: Workspace,
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
@@ -78,34 +79,16 @@ export class ProductEditorController {
|
|||||||
requiredUuid('profile_id', profileId),
|
requiredUuid('profile_id', profileId),
|
||||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
query,
|
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')
|
@Get('product-platform-profiles/:profileId/attribute-schema')
|
||||||
async attributeSchema(
|
async attributeSchema(
|
||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@Query('category_id') categoryId: string | undefined,
|
@Query('category_id') categoryId: string | undefined,
|
||||||
|
@Query('store_id') storeId: string | undefined,
|
||||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
@CurrentWorkspace() workspace: Workspace,
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
@@ -115,6 +98,7 @@ export class ProductEditorController {
|
|||||||
requiredUuid('profile_id', profileId),
|
requiredUuid('profile_id', profileId),
|
||||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
optionalUuid('category_id', categoryId),
|
optionalUuid('category_id', categoryId),
|
||||||
|
optionalUuid('store_id', storeId),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -149,6 +133,7 @@ export class ProductEditorController {
|
|||||||
@Param('dictionaryId') dictionaryId: string,
|
@Param('dictionaryId') dictionaryId: string,
|
||||||
@Query('q') query: string | undefined,
|
@Query('q') query: string | undefined,
|
||||||
@Query('category_id') categoryId: string | undefined,
|
@Query('category_id') categoryId: string | undefined,
|
||||||
|
@Query('store_id') storeId: string | undefined,
|
||||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
@CurrentWorkspace() workspace: Workspace,
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
@@ -162,6 +147,7 @@ export class ProductEditorController {
|
|||||||
dictionaryId,
|
dictionaryId,
|
||||||
query,
|
query,
|
||||||
optionalUuid('category_id', categoryId),
|
optionalUuid('category_id', categoryId),
|
||||||
|
optionalUuid('store_id', storeId),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type ProfileRow = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
tags: unknown;
|
tags: unknown;
|
||||||
platform_product_category_id: string | null;
|
store_product_category_id: string | null;
|
||||||
platform_type_id: string | null;
|
platform_type_id: string | null;
|
||||||
category_source: string | null;
|
category_source: string | null;
|
||||||
category_name: string | null;
|
category_name: string | null;
|
||||||
@@ -110,13 +110,18 @@ export class ProductEditorService {
|
|||||||
product.id,
|
product.id,
|
||||||
existing,
|
existing,
|
||||||
);
|
);
|
||||||
|
const profile = await this.applyCapturedCategory(
|
||||||
|
transaction,
|
||||||
|
product,
|
||||||
|
existing,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
data: this.toContext(
|
data: this.toContext(
|
||||||
collectionItemId,
|
collectionItemId,
|
||||||
product.id,
|
product.id,
|
||||||
existing,
|
profile,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -145,13 +150,18 @@ export class ProductEditorService {
|
|||||||
if (!profile)
|
if (!profile)
|
||||||
throw new Error('商品编辑上下文创建后无法读取发布配置');
|
throw new Error('商品编辑上下文创建后无法读取发布配置');
|
||||||
await this.ensureListings(transaction, product.id, profile);
|
await this.ensureListings(transaction, product.id, profile);
|
||||||
|
const resolvedProfile = await this.applyCapturedCategory(
|
||||||
|
transaction,
|
||||||
|
product,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
status: created.rows[0] ? 201 : 200,
|
status: created.rows[0] ? 201 : 200,
|
||||||
body: {
|
body: {
|
||||||
data: this.toContext(
|
data: this.toContext(
|
||||||
collectionItemId,
|
collectionItemId,
|
||||||
product.id,
|
product.id,
|
||||||
profile,
|
resolvedProfile,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -172,17 +182,22 @@ export class ProductEditorService {
|
|||||||
transaction,
|
transaction,
|
||||||
productId,
|
productId,
|
||||||
);
|
);
|
||||||
const profile = await this.findProfile(
|
const initialProfile = await this.findProfile(
|
||||||
transaction,
|
transaction,
|
||||||
productId,
|
productId,
|
||||||
platformId,
|
platformId,
|
||||||
);
|
);
|
||||||
if (!profile)
|
if (!initialProfile)
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
409,
|
409,
|
||||||
'EDITOR_CONTEXT_REQUIRED',
|
'EDITOR_CONTEXT_REQUIRED',
|
||||||
'请先从采集箱进入商品编辑器',
|
'请先从采集箱进入商品编辑器',
|
||||||
);
|
);
|
||||||
|
const profile = await this.applyCapturedCategory(
|
||||||
|
transaction,
|
||||||
|
product,
|
||||||
|
initialProfile,
|
||||||
|
);
|
||||||
const variants = await transaction.query<{
|
const variants = await transaction.query<{
|
||||||
id: string;
|
id: string;
|
||||||
source_variant_key: string | null;
|
source_variant_key: string | null;
|
||||||
@@ -359,7 +374,7 @@ export class ProductEditorService {
|
|||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
category: {
|
category: {
|
||||||
id: profile.platform_product_category_id,
|
id: profile.store_product_category_id,
|
||||||
platform_type_id: profile.platform_type_id,
|
platform_type_id: profile.platform_type_id,
|
||||||
source: profile.category_source,
|
source: profile.category_source,
|
||||||
name: profile.category_name,
|
name: profile.category_name,
|
||||||
@@ -427,6 +442,7 @@ export class ProductEditorService {
|
|||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
search: string | undefined,
|
search: string | undefined,
|
||||||
|
storeId?: string,
|
||||||
) {
|
) {
|
||||||
return this.database.workspaceTransaction(
|
return this.database.workspaceTransaction(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -437,6 +453,9 @@ export class ProductEditorService {
|
|||||||
platformId,
|
platformId,
|
||||||
);
|
);
|
||||||
const needle = search?.trim() ?? '';
|
const needle = search?.trim() ?? '';
|
||||||
|
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||||
|
if (storeId)
|
||||||
|
await this.requireEditableStore(transaction, storeId, platformId);
|
||||||
const categories = await transaction.query<{
|
const categories = await transaction.query<{
|
||||||
id: string;
|
id: string;
|
||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
@@ -447,11 +466,11 @@ export class ProductEditorService {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}>(
|
}>(
|
||||||
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
||||||
FROM atlas.platform_product_categories
|
FROM atlas.store_product_categories
|
||||||
WHERE platform_id = $1
|
WHERE store_id = $1
|
||||||
ORDER BY path, name, id
|
ORDER BY path, name, id
|
||||||
LIMIT 10000`,
|
LIMIT 10000`,
|
||||||
[profile.platform_id],
|
[selectedStoreId],
|
||||||
);
|
);
|
||||||
return categories.rows;
|
return categories.rows;
|
||||||
},
|
},
|
||||||
@@ -463,6 +482,7 @@ export class ProductEditorService {
|
|||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
|
storeId?: string,
|
||||||
) {
|
) {
|
||||||
return this.database.workspaceTransaction(
|
return this.database.workspaceTransaction(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -473,7 +493,10 @@ export class ProductEditorService {
|
|||||||
platformId,
|
platformId,
|
||||||
);
|
);
|
||||||
const selectedCategoryId =
|
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)
|
if (!selectedCategoryId)
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
@@ -483,12 +506,12 @@ export class ProductEditorService {
|
|||||||
if (categoryId)
|
if (categoryId)
|
||||||
await this.requireActiveCategory(
|
await this.requireActiveCategory(
|
||||||
transaction,
|
transaction,
|
||||||
profile.platform_id,
|
selectedStoreId,
|
||||||
categoryId,
|
categoryId,
|
||||||
);
|
);
|
||||||
return this.attributeSchemaForCategory(
|
return this.attributeSchemaForCategory(
|
||||||
transaction,
|
transaction,
|
||||||
profile.platform_id,
|
selectedStoreId,
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -502,6 +525,7 @@ export class ProductEditorService {
|
|||||||
dictionaryId: string,
|
dictionaryId: string,
|
||||||
search: string | undefined,
|
search: string | undefined,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
|
storeId?: string,
|
||||||
) {
|
) {
|
||||||
return this.database.workspaceTransaction(
|
return this.database.workspaceTransaction(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -512,7 +536,10 @@ export class ProductEditorService {
|
|||||||
platformId,
|
platformId,
|
||||||
);
|
);
|
||||||
const selectedCategoryId =
|
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)
|
if (!selectedCategoryId)
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
@@ -522,18 +549,18 @@ export class ProductEditorService {
|
|||||||
if (categoryId)
|
if (categoryId)
|
||||||
await this.requireActiveCategory(
|
await this.requireActiveCategory(
|
||||||
transaction,
|
transaction,
|
||||||
profile.platform_id,
|
selectedStoreId,
|
||||||
categoryId,
|
categoryId,
|
||||||
);
|
);
|
||||||
const allowed = await transaction.query<{
|
const allowed = await transaction.query<{
|
||||||
dictionary_id: string;
|
dictionary_id: string;
|
||||||
}>(
|
}>(
|
||||||
`SELECT dictionary_id
|
`SELECT dictionary_id
|
||||||
FROM atlas.platform_product_category_attributes
|
FROM atlas.store_product_category_attributes
|
||||||
WHERE platform_id = $1 AND platform_product_category_id = $2
|
WHERE store_id = $1 AND store_product_category_id = $2
|
||||||
AND dictionary_id = $3
|
AND dictionary_id = $3
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[profile.platform_id, selectedCategoryId, dictionaryId],
|
[selectedStoreId, selectedCategoryId, dictionaryId],
|
||||||
);
|
);
|
||||||
if (!allowed.rows[0])
|
if (!allowed.rows[0])
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
@@ -547,40 +574,19 @@ export class ProductEditorService {
|
|||||||
value: string;
|
value: string;
|
||||||
}>(
|
}>(
|
||||||
`SELECT external_value_id AS value_id, value
|
`SELECT external_value_id AS value_id, value
|
||||||
FROM atlas.platform_attribute_dictionary_values
|
FROM atlas.store_attribute_dictionary_values
|
||||||
WHERE platform_id = $1 AND dictionary_id = $2 AND is_active
|
WHERE store_id = $1 AND store_product_category_id = $2
|
||||||
AND ($3 = '' OR value ILIKE '%' || $3 || '%')
|
AND dictionary_id = $3 AND is_active
|
||||||
|
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
|
||||||
ORDER BY value, external_value_id
|
ORDER BY value, external_value_id
|
||||||
LIMIT 100`,
|
LIMIT 100`,
|
||||||
[profile.platform_id, dictionaryId, needle],
|
[selectedStoreId, selectedCategoryId, dictionaryId, needle],
|
||||||
);
|
);
|
||||||
return { items: values.rows, next_cursor: null };
|
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(
|
async requestAttributeSchemaSync(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -682,18 +688,18 @@ export class ProductEditorService {
|
|||||||
platformId,
|
platformId,
|
||||||
);
|
);
|
||||||
const selectedCategoryId =
|
const selectedCategoryId =
|
||||||
input.profile.platform_product_category_id === undefined
|
input.profile.store_product_category_id === undefined
|
||||||
? profile.platform_product_category_id
|
? profile.store_product_category_id
|
||||||
: input.profile.platform_product_category_id;
|
: input.profile.store_product_category_id;
|
||||||
const selectedCategory = selectedCategoryId
|
const selectedCategory = selectedCategoryId
|
||||||
? await this.requireActiveCategory(
|
? await this.requireActiveCategory(
|
||||||
transaction,
|
transaction,
|
||||||
platformId,
|
input.profile.target_store_id,
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const categoryChanged =
|
const categoryChanged =
|
||||||
selectedCategoryId !== profile.platform_product_category_id;
|
selectedCategoryId !== profile.store_product_category_id;
|
||||||
const categorySource = selectedCategory
|
const categorySource = selectedCategory
|
||||||
? categoryChanged
|
? categoryChanged
|
||||||
? 'manual'
|
? 'manual'
|
||||||
@@ -758,7 +764,7 @@ export class ProductEditorService {
|
|||||||
}>(
|
}>(
|
||||||
`UPDATE atlas.product_platform_profiles
|
`UPDATE atlas.product_platform_profiles
|
||||||
SET target_store_id = $1, fulfillment = $2, title = $3, description = $4,
|
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,
|
platform_type_id = $7, category_source = $8,
|
||||||
revision = revision + 1, updated_at = now()
|
revision = revision + 1, updated_at = now()
|
||||||
WHERE id = $9 AND product_id = $10 AND platform_id = $11 AND revision = $12
|
WHERE id = $9 AND product_id = $10 AND platform_id = $11 AND revision = $12
|
||||||
@@ -993,6 +999,7 @@ export class ProductEditorService {
|
|||||||
productId,
|
productId,
|
||||||
profile,
|
profile,
|
||||||
platformId,
|
platformId,
|
||||||
|
targetStoreId: input.profile.target_store_id,
|
||||||
categoryId: selectedCategory?.id ?? null,
|
categoryId: selectedCategory?.id ?? null,
|
||||||
attributes: input.attributes,
|
attributes: input.attributes,
|
||||||
retainedVariantIds: retainedIds,
|
retainedVariantIds: retainedIds,
|
||||||
@@ -1044,6 +1051,7 @@ export class ProductEditorService {
|
|||||||
productId: string;
|
productId: string;
|
||||||
profile: ProfileRow;
|
profile: ProfileRow;
|
||||||
platformId: string;
|
platformId: string;
|
||||||
|
targetStoreId: string;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
attributes: SaveProductEditorDraftRequest['attributes'];
|
attributes: SaveProductEditorDraftRequest['attributes'];
|
||||||
retainedVariantIds: string[];
|
retainedVariantIds: string[];
|
||||||
@@ -1065,15 +1073,15 @@ export class ProductEditorService {
|
|||||||
a.confirmation_status,
|
a.confirmation_status,
|
||||||
coalesce(schema.name, a.platform_attribute_id) AS original_name
|
coalesce(schema.name, a.platform_attribute_id) AS original_name
|
||||||
FROM atlas.product_attributes a
|
FROM atlas.product_attributes a
|
||||||
LEFT JOIN atlas.platform_product_category_attributes schema
|
LEFT JOIN atlas.store_product_category_attributes schema
|
||||||
ON schema.platform_id = $1
|
ON schema.store_id = $1
|
||||||
AND schema.platform_product_category_id IS NOT DISTINCT FROM $2::uuid
|
AND schema.store_product_category_id IS NOT DISTINCT FROM $2::uuid
|
||||||
AND schema.platform_attribute_id = a.platform_attribute_id
|
AND schema.platform_attribute_id = a.platform_attribute_id
|
||||||
AND schema.complex_id = a.complex_id
|
AND schema.complex_id = a.complex_id
|
||||||
WHERE a.profile_id = $3`,
|
WHERE a.profile_id = $3`,
|
||||||
[
|
[
|
||||||
input.platformId,
|
input.profile.target_store_id,
|
||||||
input.profile.platform_product_category_id,
|
input.profile.store_product_category_id,
|
||||||
input.profile.id,
|
input.profile.id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1088,7 +1096,7 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
const schema = await this.attributeSchemaForCategory(
|
const schema = await this.attributeSchemaForCategory(
|
||||||
transaction,
|
transaction,
|
||||||
input.platformId,
|
input.profile.target_store_id,
|
||||||
input.categoryId,
|
input.categoryId,
|
||||||
);
|
);
|
||||||
const schemaByKey = new Map(
|
const schemaByKey = new Map(
|
||||||
@@ -1183,7 +1191,8 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
await this.assertAttributeValues(
|
await this.assertAttributeValues(
|
||||||
transaction,
|
transaction,
|
||||||
input.platformId,
|
input.targetStoreId,
|
||||||
|
input.categoryId,
|
||||||
definition,
|
definition,
|
||||||
attribute.values,
|
attribute.values,
|
||||||
);
|
);
|
||||||
@@ -1213,7 +1222,7 @@ export class ProductEditorService {
|
|||||||
await transaction.query(
|
await transaction.query(
|
||||||
`INSERT INTO atlas.product_unmapped_attributes
|
`INSERT INTO atlas.product_unmapped_attributes
|
||||||
(workspace_id, product_id, profile_id, platform_id, product_variant_id,
|
(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)
|
original_complex_id, original_name, values, language_code, reason)
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
|
||||||
$5, $6, $7, $8, $9::jsonb, $10, 'category_changed')`,
|
$5, $6, $7, $8, $9::jsonb, $10, 'category_changed')`,
|
||||||
@@ -1222,7 +1231,7 @@ export class ProductEditorService {
|
|||||||
input.profile.id,
|
input.profile.id,
|
||||||
input.platformId,
|
input.platformId,
|
||||||
attribute.product_variant_id,
|
attribute.product_variant_id,
|
||||||
input.profile.platform_product_category_id,
|
input.profile.store_product_category_id,
|
||||||
attribute.platform_attribute_id,
|
attribute.platform_attribute_id,
|
||||||
attribute.complex_id,
|
attribute.complex_id,
|
||||||
attribute.original_name,
|
attribute.original_name,
|
||||||
@@ -1261,7 +1270,8 @@ export class ProductEditorService {
|
|||||||
|
|
||||||
private async assertAttributeValues(
|
private async assertAttributeValues(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
platformId: string,
|
storeId: string,
|
||||||
|
categoryId: string,
|
||||||
definition: Awaited<
|
definition: Awaited<
|
||||||
ReturnType<ProductEditorService['attributeSchemaForCategory']>
|
ReturnType<ProductEditorService['attributeSchemaForCategory']>
|
||||||
>[number],
|
>[number],
|
||||||
@@ -1307,10 +1317,11 @@ export class ProductEditorService {
|
|||||||
return;
|
return;
|
||||||
const dictionary = await transaction.query<{ value_id: string }>(
|
const dictionary = await transaction.query<{ value_id: string }>(
|
||||||
`SELECT external_value_id AS value_id
|
`SELECT external_value_id AS value_id
|
||||||
FROM atlas.platform_attribute_dictionary_values
|
FROM atlas.store_attribute_dictionary_values
|
||||||
WHERE platform_id = $1 AND dictionary_id = $2 AND is_active
|
WHERE store_id = $1 AND store_product_category_id = $2
|
||||||
AND external_value_id = ANY($3::text[])`,
|
AND dictionary_id = $3 AND is_active
|
||||||
[platformId, definition.dictionary_id, values],
|
AND external_value_id = ANY($4::text[])`,
|
||||||
|
[storeId, categoryId, definition.dictionary_id, values],
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
new Set(dictionary.rows.map((item) => item.value_id)).size !==
|
new Set(dictionary.rows.map((item) => item.value_id)).size !==
|
||||||
@@ -1325,7 +1336,7 @@ export class ProductEditorService {
|
|||||||
|
|
||||||
private async attributeSchemaForCategory(
|
private async attributeSchemaForCategory(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
platformId: string,
|
storeId: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
) {
|
) {
|
||||||
const result = await transaction.query<{
|
const result = await transaction.query<{
|
||||||
@@ -1342,10 +1353,10 @@ export class ProductEditorService {
|
|||||||
}>(
|
}>(
|
||||||
`SELECT platform_attribute_id, complex_id, name, value_type,
|
`SELECT platform_attribute_id, complex_id, name, value_type,
|
||||||
is_required, is_collection, max_value_count, dictionary_id, is_aspect
|
is_required, is_collection, max_value_count, dictionary_id, is_aspect
|
||||||
FROM atlas.platform_product_category_attributes
|
FROM atlas.store_product_category_attributes
|
||||||
WHERE platform_id = $1 AND platform_product_category_id = $2
|
WHERE store_id = $1 AND store_product_category_id = $2
|
||||||
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
|
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
|
||||||
[platformId, categoryId],
|
[storeId, categoryId],
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
@@ -1356,7 +1367,7 @@ export class ProductEditorService {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
platformId: string;
|
platformId: string;
|
||||||
key: string;
|
key: string;
|
||||||
scope: 'tree' | 'attributes' | 'dictionary';
|
scope: 'attributes' | 'dictionary';
|
||||||
path: string;
|
path: string;
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
dictionaryId?: string;
|
dictionaryId?: string;
|
||||||
@@ -1382,7 +1393,6 @@ export class ProductEditorService {
|
|||||||
input.profileId,
|
input.profileId,
|
||||||
input.platformId,
|
input.platformId,
|
||||||
);
|
);
|
||||||
if (input.scope !== 'tree') {
|
|
||||||
if (!input.categoryId)
|
if (!input.categoryId)
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
@@ -1391,17 +1401,16 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
await this.requireActiveCategory(
|
await this.requireActiveCategory(
|
||||||
transaction,
|
transaction,
|
||||||
profile.platform_id,
|
profile.target_store_id,
|
||||||
input.categoryId,
|
input.categoryId,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (input.scope === 'dictionary') {
|
if (input.scope === 'dictionary') {
|
||||||
const allowed = await transaction.query<{ id: string }>(
|
const allowed = await transaction.query<{ id: string }>(
|
||||||
`SELECT id FROM atlas.platform_product_category_attributes
|
`SELECT id FROM atlas.store_product_category_attributes
|
||||||
WHERE platform_id = $1 AND platform_product_category_id = $2
|
WHERE store_id = $1 AND store_product_category_id = $2
|
||||||
AND dictionary_id = $3 LIMIT 1`,
|
AND dictionary_id = $3 LIMIT 1`,
|
||||||
[
|
[
|
||||||
profile.platform_id,
|
profile.target_store_id,
|
||||||
input.categoryId,
|
input.categoryId,
|
||||||
input.dictionaryId,
|
input.dictionaryId,
|
||||||
],
|
],
|
||||||
@@ -1443,14 +1452,12 @@ export class ProductEditorService {
|
|||||||
'当前平台暂不支持类目缓存同步',
|
'当前平台暂不支持类目缓存同步',
|
||||||
);
|
);
|
||||||
const kinds = {
|
const kinds = {
|
||||||
tree: 'platform_category_tree_sync',
|
attributes: 'store_category_attribute_sync',
|
||||||
attributes: 'platform_category_attribute_sync',
|
dictionary: 'store_attribute_dictionary_sync',
|
||||||
dictionary: 'platform_attribute_dictionary_sync',
|
|
||||||
} as const;
|
} as const;
|
||||||
const eventTypes = {
|
const eventTypes = {
|
||||||
tree: 'platform_category_tree_sync.requested',
|
attributes: 'store_category_attribute_sync.requested',
|
||||||
attributes: 'platform_category_attribute_sync.requested',
|
dictionary: 'store_attribute_dictionary_sync.requested',
|
||||||
dictionary: 'platform_attribute_dictionary_sync.requested',
|
|
||||||
} as const;
|
} as const;
|
||||||
const created = await transaction.query<OperationRow>(
|
const created = await transaction.query<OperationRow>(
|
||||||
`INSERT INTO atlas.operations
|
`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(
|
private async requireActiveCategory(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
platformId: string,
|
storeId: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
) {
|
) {
|
||||||
const result = await transaction.query<{
|
const result = await transaction.query<{
|
||||||
@@ -1499,15 +1559,15 @@ export class ProductEditorService {
|
|||||||
external_type_id: string | null;
|
external_type_id: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT id, external_type_id
|
`SELECT id, external_type_id
|
||||||
FROM atlas.platform_product_categories
|
FROM atlas.store_product_categories
|
||||||
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
WHERE id = $1 AND store_id = $2 AND is_active`,
|
||||||
[categoryId, platformId],
|
[categoryId, storeId],
|
||||||
);
|
);
|
||||||
if (!result.rows[0])
|
if (!result.rows[0])
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
'CATEGORY_INVALID',
|
'CATEGORY_INVALID',
|
||||||
'类目不存在、已停用或不属于当前平台',
|
'类目不存在、已停用或不属于当前店铺',
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
@@ -1849,15 +1909,15 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code,
|
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,
|
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,
|
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
||||||
s.currency_code
|
s.currency_code
|
||||||
FROM atlas.product_platform_profiles p
|
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
|
LEFT JOIN atlas.store_product_categories category
|
||||||
ON category.id = p.platform_product_category_id
|
ON category.id = p.store_product_category_id
|
||||||
AND category.platform_id = p.platform_id`;
|
AND category.store_id = p.target_store_id`;
|
||||||
|
|
||||||
type VariantSpecRow = {
|
type VariantSpecRow = {
|
||||||
specification_values: unknown;
|
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 {
|
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
|
|||||||
@@ -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 集成测试', () => {
|
describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
Object.assign(process.env, {
|
Object.assign(process.env, {
|
||||||
@@ -117,6 +142,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1',
|
'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.store_product_categories WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
'DELETE FROM atlas.products WHERE workspace_id = $1',
|
'DELETE FROM atlas.products WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
@@ -137,19 +166,6 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
]);
|
]);
|
||||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
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();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -834,44 +850,33 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
const materialDictionaryId = `m34-material-${dictionarySuffix}`;
|
const materialDictionaryId = `m34-material-${dictionarySuffix}`;
|
||||||
const colorDictionaryId = `m34-color-${dictionarySuffix}`;
|
const colorDictionaryId = `m34-color-${dictionarySuffix}`;
|
||||||
seededDictionaryIds.push(materialDictionaryId, colorDictionaryId);
|
seededDictionaryIds.push(materialDictionaryId, colorDictionaryId);
|
||||||
const categoryA = await database.query<{ id: string }>(
|
const categoryA = await seedStoreCategory(`m34-a-${randomUUID()}`, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱');
|
||||||
`INSERT INTO atlas.platform_product_categories
|
const categoryB = await seedStoreCategory(`m34-b-${randomUUID()}`, 'type-b', 'M3.4 背包', '箱包 › 背包 › M3.4 背包');
|
||||||
(platform_id, external_category_id, external_type_id, name, path)
|
seededCategoryIds.push(categoryA.id, categoryB.id);
|
||||||
VALUES ($1, $2, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱')
|
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||||
RETURNING id`,
|
`INSERT INTO atlas.store_product_category_attributes
|
||||||
[platformId, `m34-a-${randomUUID()}`],
|
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
||||||
);
|
|
||||||
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)
|
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, 'material', '材质', 'dictionary', true, false, 1, $4, false),
|
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'material', '材质', 'dictionary', true, false, 1, $5, false),
|
||||||
($1, $2, 'color', '颜色', 'dictionary', true, false, 1, $5, true),
|
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'color', '颜色', 'dictionary', true, false, 1, $6, true),
|
||||||
($1, $3, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
|
(current_setting('app.workspace_id')::uuid, $1, $2, $4, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
|
||||||
[
|
[
|
||||||
|
categoryA.storeId,
|
||||||
platformId,
|
platformId,
|
||||||
categoryA.rows[0].id,
|
categoryA.id,
|
||||||
categoryB.rows[0].id,
|
categoryB.id,
|
||||||
materialDictionaryId,
|
materialDictionaryId,
|
||||||
colorDictionaryId,
|
colorDictionaryId,
|
||||||
],
|
],
|
||||||
);
|
));
|
||||||
await database.query(
|
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||||
`INSERT INTO atlas.platform_attribute_dictionary_values
|
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||||
(platform_id, dictionary_id, external_value_id, value)
|
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value)
|
||||||
VALUES ($1, $2, 'cotton', '棉'),
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, 'cotton', '棉'),
|
||||||
($1, $3, 'black', '黑色')`,
|
(current_setting('app.workspace_id')::uuid, $1, $2, $3, $5, 'black', '黑色')`,
|
||||||
[platformId, materialDictionaryId, colorDictionaryId],
|
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId, colorDictionaryId],
|
||||||
);
|
));
|
||||||
const categories = await request(
|
const categories = await request(
|
||||||
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
||||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
@@ -879,7 +884,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
assert.equal(categories.response.status, 200);
|
assert.equal(categories.response.status, 200);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
categories.body.data.some(
|
categories.body.data.some(
|
||||||
(item: { id: string }) => item.id === categoryA.rows[0].id,
|
(item: { id: string }) => item.id === categoryA.id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const before = await request(
|
const before = await request(
|
||||||
@@ -917,7 +922,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
title: before.body.data.profile.title,
|
title: before.body.data.profile.title,
|
||||||
description: before.body.data.profile.description,
|
description: before.body.data.profile.description,
|
||||||
tags: before.body.data.profile.tags,
|
tags: before.body.data.profile.tags,
|
||||||
platform_product_category_id: categoryA.rows[0].id,
|
store_product_category_id: categoryA.id,
|
||||||
category_source: 'manual',
|
category_source: 'manual',
|
||||||
},
|
},
|
||||||
attributes: [
|
attributes: [
|
||||||
@@ -958,7 +963,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
);
|
);
|
||||||
assert.equal(saved.response.status, 200);
|
assert.equal(saved.response.status, 200);
|
||||||
const schema = await request(
|
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 }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
);
|
);
|
||||||
assert.equal(schema.response.status, 200);
|
assert.equal(schema.response.status, 200);
|
||||||
@@ -970,7 +975,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const dictionary = await request(
|
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 }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
);
|
);
|
||||||
assert.deepEqual(dictionary.body.data.items, [
|
assert.deepEqual(dictionary.body.data.items, [
|
||||||
@@ -984,7 +989,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
afterSave.body.data.profile.category.id,
|
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.profile.category.name, 'M3.4 收纳箱');
|
||||||
assert.equal(afterSave.body.data.attributes.length, 2);
|
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,
|
afterSave.body.data.source_material_state.overridden_fields,
|
||||||
profile: {
|
profile: {
|
||||||
...draft.profile,
|
...draft.profile,
|
||||||
platform_product_category_id: categoryB.rows[0].id,
|
store_product_category_id: categoryB.id,
|
||||||
},
|
},
|
||||||
attributes: afterSave.body.data.attributes.map(
|
attributes: afterSave.body.data.attributes.map(
|
||||||
(attribute: any) => ({
|
(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 dictionaryId = `m35-dictionary-${randomUUID()}`;
|
||||||
const category = await database.query<{ id: string }>(
|
const category = await seedStoreCategory(`m35-category-${randomUUID()}`, 'm35-type', 'M3.5 测试类目', '测试 › M3.5 测试类目');
|
||||||
`INSERT INTO atlas.platform_product_categories
|
seededCategoryIds.push(category.id);
|
||||||
(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);
|
seededDictionaryIds.push(dictionaryId);
|
||||||
await database.query(
|
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||||
`INSERT INTO atlas.platform_product_category_attributes
|
`INSERT INTO atlas.store_product_category_attributes
|
||||||
(platform_id, platform_product_category_id, platform_attribute_id, name,
|
(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)
|
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)`,
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $4, false)`,
|
||||||
[platformId, category.rows[0].id, dictionaryId],
|
[category.storeId, platformId, category.id, dictionaryId],
|
||||||
);
|
));
|
||||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
const profile = await tx.query<{ target_store_id: string }>(
|
const profile = await tx.query<{ target_store_id: string }>(
|
||||||
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
|
'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(
|
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',
|
method: 'POST',
|
||||||
headers: headers({
|
headers: headers({
|
||||||
@@ -1105,9 +1157,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.equal(schema.response.status, 202);
|
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(
|
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',
|
method: 'POST',
|
||||||
headers: headers({
|
headers: headers({
|
||||||
@@ -1117,21 +1169,20 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.equal(dictionary.response.status, 202);
|
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) => {
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
const events = await tx.query<{ event_type: string; event_payload: unknown }>(
|
const events = await tx.query<{ event_type: string; event_payload: unknown }>(
|
||||||
`SELECT event_type, event_payload FROM atlas.outbox_events
|
`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`,
|
ORDER BY created_at`,
|
||||||
[first.profileId],
|
[first.profileId],
|
||||||
);
|
);
|
||||||
assert.equal(events.rows.length, 3);
|
assert.equal(events.rows.length, 2);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
events.rows.map((event) => event.event_type),
|
events.rows.map((event) => event.event_type),
|
||||||
[
|
[
|
||||||
'platform_category_tree_sync.requested',
|
'store_category_attribute_sync.requested',
|
||||||
'platform_category_attribute_sync.requested',
|
'store_attribute_dictionary_sync.requested',
|
||||||
'platform_attribute_dictionary_sync.requested',
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
|||||||
title: snapshot.profile.title,
|
title: snapshot.profile.title,
|
||||||
description: snapshot.profile.description,
|
description: snapshot.profile.description,
|
||||||
tags: snapshot.profile.tags,
|
tags: snapshot.profile.tags,
|
||||||
platform_product_category_id: snapshot.profile.category.id,
|
store_product_category_id: snapshot.profile.category.id,
|
||||||
category_source: snapshot.profile.category.source as
|
category_source: snapshot.profile.category.source as
|
||||||
'source_capture' | 'ai_suggestion' | 'manual' | null,
|
'source_capture' | 'ai_suggestion' | 'manual' | null,
|
||||||
},
|
},
|
||||||
@@ -430,7 +430,8 @@ const ProductEditPage = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm<EditorFormValues>();
|
const [form] = Form.useForm<EditorFormValues>();
|
||||||
const supplyUrl = Form.useWatch(['common', 'purchase_url'], form);
|
const supplyUrl = Form.useWatch(['common', 'purchase_url'], form);
|
||||||
const selectedCategoryId = Form.useWatch(['profile', 'platform_product_category_id'], form);
|
const selectedCategoryId = Form.useWatch(['profile', 'store_product_category_id'], form);
|
||||||
|
const selectedStoreId = Form.useWatch(['profile', 'target_store_id'], form);
|
||||||
const draftAttributes = Form.useWatch('attributes', form) ?? [];
|
const draftAttributes = Form.useWatch('attributes', form) ?? [];
|
||||||
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
|
||||||
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
|
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
|
||||||
@@ -480,7 +481,7 @@ const ProductEditPage = () => {
|
|||||||
if (!snapshot || !currentPlatform) return;
|
if (!snapshot || !currentPlatform) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
void productEditorApi
|
void productEditorApi
|
||||||
.categoryOptions(snapshot.profile.id, currentPlatform.id)
|
.categoryOptions(snapshot.profile.id, currentPlatform.id, undefined, selectedStoreId)
|
||||||
.then((options) => {
|
.then((options) => {
|
||||||
if (active) setCategoryOptions(options);
|
if (active) setCategoryOptions(options);
|
||||||
})
|
})
|
||||||
@@ -490,7 +491,7 @@ const ProductEditPage = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [catalogRefresh, currentPlatform, snapshot]);
|
}, [catalogRefresh, currentPlatform, selectedStoreId, snapshot]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) {
|
if (!snapshot || !currentPlatform || !selectedCategoryId) {
|
||||||
setAttributeSchema([]);
|
setAttributeSchema([]);
|
||||||
@@ -499,7 +500,7 @@ const ProductEditPage = () => {
|
|||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
void productEditorApi
|
void productEditorApi
|
||||||
.attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId)
|
.attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId, selectedStoreId)
|
||||||
.then((schema) => {
|
.then((schema) => {
|
||||||
if (active) setAttributeSchema(schema);
|
if (active) setAttributeSchema(schema);
|
||||||
})
|
})
|
||||||
@@ -509,7 +510,7 @@ const ProductEditPage = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [catalogRefresh, currentPlatform, selectedCategoryId, snapshot]);
|
}, [catalogRefresh, currentPlatform, selectedCategoryId, selectedStoreId, snapshot]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||||
const dictionaryIds = [
|
const dictionaryIds = [
|
||||||
@@ -534,7 +535,8 @@ const ProductEditPage = () => {
|
|||||||
currentPlatform.id,
|
currentPlatform.id,
|
||||||
dictionaryId,
|
dictionaryId,
|
||||||
undefined,
|
undefined,
|
||||||
selectedCategoryId
|
selectedCategoryId,
|
||||||
|
selectedStoreId,
|
||||||
),
|
),
|
||||||
] as const
|
] as const
|
||||||
)
|
)
|
||||||
@@ -551,7 +553,7 @@ const ProductEditPage = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [attributeSchema, catalogRefresh, currentPlatform, selectedCategoryId, snapshot]);
|
}, [attributeSchema, catalogRefresh, currentPlatform, selectedCategoryId, selectedStoreId, snapshot]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || !currentPlatform) return;
|
if (!snapshot || !currentPlatform) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -784,7 +786,8 @@ const ProductEditPage = () => {
|
|||||||
currentPlatform.id,
|
currentPlatform.id,
|
||||||
dictionaryId,
|
dictionaryId,
|
||||||
query,
|
query,
|
||||||
selectedCategoryId
|
selectedCategoryId,
|
||||||
|
selectedStoreId,
|
||||||
);
|
);
|
||||||
setDictionaryValues((current) => ({ ...current, [dictionaryId]: page.items }));
|
setDictionaryValues((current) => ({ ...current, [dictionaryId]: page.items }));
|
||||||
};
|
};
|
||||||
@@ -888,18 +891,31 @@ const ProductEditPage = () => {
|
|||||||
label: `${store.name}(${store.currency_code})`,
|
label: `${store.name}(${store.currency_code})`,
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
onChange={() => {
|
||||||
|
form.setFieldValue(['profile', 'store_product_category_id'], null);
|
||||||
|
form.setFieldValue(['profile', 'category_source'], null);
|
||||||
|
form.setFieldValue('attributes', []);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={['profile', 'platform_product_category_id']}
|
name={['profile', 'store_product_category_id']}
|
||||||
label="产品类目"
|
label="产品类目"
|
||||||
className="span-full"
|
className="span-full"
|
||||||
rules={[{ required: true, message: '请选择产品类目' }]}
|
rules={[{ required: true, message: '请选择产品类目' }]}
|
||||||
|
getValueProps={(categoryId: string | null) => ({
|
||||||
|
value: categoryId
|
||||||
|
? (categories.paths.get(categoryId) ?? [categoryId])
|
||||||
|
: [],
|
||||||
|
})}
|
||||||
|
getValueFromEvent={(value: string[]) =>
|
||||||
|
value.at(-1) ?? null
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Cascader
|
<Cascader
|
||||||
className="editor-standard-cascader"
|
className="editor-standard-cascader"
|
||||||
showSearch
|
showSearch
|
||||||
placeholder="选择当前平台的产品类目"
|
placeholder="选择当前店铺可发布的产品类目"
|
||||||
options={categories.options}
|
options={categories.options}
|
||||||
notFoundContent="类目缓存正在准备中,请稍后重试"
|
notFoundContent="类目缓存正在准备中,请稍后重试"
|
||||||
getPopupContainer={(node) => node.parentElement ?? document.body}
|
getPopupContainer={(node) => node.parentElement ?? document.body}
|
||||||
|
|||||||
@@ -39,30 +39,19 @@ export const productEditorApi = {
|
|||||||
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
categoryOptions: (profileId: string, platformId: string, search?: string) =>
|
categoryOptions: (profileId: string, platformId: string, search?: string, storeId?: string | null) =>
|
||||||
api.get<ProductEditorCategoryOption[]>(
|
api.get<ProductEditorCategoryOption[]>(
|
||||||
`/product-platform-profiles/${profileId}/category-options`,
|
`/product-platform-profiles/${profileId}/category-options`,
|
||||||
{
|
{
|
||||||
params: search ? { q: search } : undefined,
|
params: { ...(search ? { q: search } : {}), ...(storeId ? { store_id: storeId } : {}) },
|
||||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
syncCategoryTree: (profileId: string, platformId: string) =>
|
attributeSchema: (profileId: string, platformId: string, categoryId?: string | null, storeId?: string | null) =>
|
||||||
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<ProductAttributeSchema[]>(
|
api.get<ProductAttributeSchema[]>(
|
||||||
`/product-platform-profiles/${profileId}/attribute-schema`,
|
`/product-platform-profiles/${profileId}/attribute-schema`,
|
||||||
{
|
{
|
||||||
params: categoryId ? { category_id: categoryId } : undefined,
|
params: { ...(categoryId ? { category_id: categoryId } : {}), ...(storeId ? { store_id: storeId } : {}) },
|
||||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -83,13 +72,15 @@ export const productEditorApi = {
|
|||||||
platformId: string,
|
platformId: string,
|
||||||
dictionaryId: string,
|
dictionaryId: string,
|
||||||
search?: string,
|
search?: string,
|
||||||
categoryId?: string | null
|
categoryId?: string | null,
|
||||||
|
storeId?: string | null,
|
||||||
) =>
|
) =>
|
||||||
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
|
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
|
||||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
|
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
...(categoryId ? { category_id: categoryId } : {}),
|
...(categoryId ? { category_id: categoryId } : {}),
|
||||||
|
...(storeId ? { store_id: storeId } : {}),
|
||||||
...(search ? { q: search } : {}),
|
...(search ? { q: search } : {}),
|
||||||
},
|
},
|
||||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
|
|||||||
+39
-6
@@ -63,9 +63,9 @@ const worker = new Worker<OutboxJob>(
|
|||||||
} else if (job.name === 'collection.assets.materialize.requested' || job.name === 'collection.assets.retry.requested') {
|
} else if (job.name === 'collection.assets.materialize.requested' || job.name === 'collection.assets.retry.requested') {
|
||||||
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data);
|
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data);
|
||||||
} else if (
|
} else if (
|
||||||
job.name === 'platform_category_tree_sync.requested' ||
|
job.name === 'store_category_tree_sync.requested' ||
|
||||||
job.name === 'platform_category_attribute_sync.requested' ||
|
job.name === 'store_category_attribute_sync.requested' ||
|
||||||
job.name === 'platform_attribute_dictionary_sync.requested'
|
job.name === 'store_attribute_dictionary_sync.requested'
|
||||||
) {
|
) {
|
||||||
await syncOzonCatalog(database, job.data as CatalogSyncJob);
|
await syncOzonCatalog(database, job.data as CatalogSyncJob);
|
||||||
}
|
}
|
||||||
@@ -86,9 +86,9 @@ worker.on('failed', (job, error) => {
|
|||||||
if (job.name === 'store_credential_verification.requested') {
|
if (job.name === 'store_credential_verification.requested') {
|
||||||
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
||||||
} else if (
|
} else if (
|
||||||
job.name === 'platform_category_tree_sync.requested' ||
|
job.name === 'store_category_tree_sync.requested' ||
|
||||||
job.name === 'platform_category_attribute_sync.requested' ||
|
job.name === 'store_category_attribute_sync.requested' ||
|
||||||
job.name === 'platform_attribute_dictionary_sync.requested'
|
job.name === 'store_attribute_dictionary_sync.requested'
|
||||||
) {
|
) {
|
||||||
void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error);
|
void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error);
|
||||||
}
|
}
|
||||||
@@ -215,6 +215,39 @@ async function completeVerification(
|
|||||||
: 'Ozon 未接受该 Client ID 或 API Key',
|
: 'Ozon 未接受该 Client ID 或 API Key',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
if (!valid) return;
|
||||||
|
const catalogOperation = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
}>(
|
||||||
|
`INSERT INTO atlas.operations
|
||||||
|
(workspace_id, kind, resource_type, resource_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid,
|
||||||
|
'store_category_tree_sync', 'store', $1)
|
||||||
|
RETURNING id`,
|
||||||
|
[job.payload.store_id],
|
||||||
|
);
|
||||||
|
const operationId = catalogOperation.rows[0]?.id;
|
||||||
|
if (!operationId) throw new Error('Unable to create store catalog sync operation');
|
||||||
|
await tx.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, 'store', $1,
|
||||||
|
'store_category_tree_sync.requested', $2::jsonb, $3)`,
|
||||||
|
[
|
||||||
|
job.payload.store_id,
|
||||||
|
JSON.stringify({
|
||||||
|
store_id: job.payload.store_id,
|
||||||
|
secret_id: job.payload.secret_id,
|
||||||
|
operation_id: operationId,
|
||||||
|
platform_id: (await tx.query<{ platform_id: string }>(
|
||||||
|
`SELECT platform_id FROM atlas.stores WHERE id = $1`,
|
||||||
|
[job.payload.store_id],
|
||||||
|
)).rows[0]?.platform_id,
|
||||||
|
scope: 'tree',
|
||||||
|
}),
|
||||||
|
operationId,
|
||||||
|
],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,7 @@ type CategorySource = {
|
|||||||
external_type_id: string | null;
|
external_type_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Cache is store-scoped because Ozon returns it under a seller credential. */
|
||||||
* 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) {
|
export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
||||||
const source = await loadSource(database, job);
|
const source = await loadSource(database, job);
|
||||||
if (!source) return cancelStaleOperation(database, job);
|
if (!source) return cancelStaleOperation(database, job);
|
||||||
@@ -63,11 +59,22 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
|||||||
try {
|
try {
|
||||||
let result: Record<string, unknown>;
|
let result: Record<string, unknown>;
|
||||||
if (job.payload.scope === 'tree') {
|
if (job.payload.scope === 'tree') {
|
||||||
const nodes = await fetchOzonCategoryTree(source.platform_client_id, apiKey);
|
const nodes = await fetchOzonCategoryTree(
|
||||||
result = await persistCategoryTree(database, job.payload.platform_id, nodes);
|
source.platform_client_id,
|
||||||
|
apiKey,
|
||||||
|
);
|
||||||
|
result = await persistCategoryTree(
|
||||||
|
database,
|
||||||
|
job.workspaceId,
|
||||||
|
job.payload.store_id,
|
||||||
|
job.payload.platform_id,
|
||||||
|
nodes,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const category = await requireCategory(
|
const category = await requireCategory(
|
||||||
database,
|
database,
|
||||||
|
job.workspaceId,
|
||||||
|
job.payload.store_id,
|
||||||
job.payload.platform_id,
|
job.payload.platform_id,
|
||||||
job.payload.category_id,
|
job.payload.category_id,
|
||||||
);
|
);
|
||||||
@@ -88,6 +95,8 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
|||||||
);
|
);
|
||||||
result = await persistAttributes(
|
result = await persistAttributes(
|
||||||
database,
|
database,
|
||||||
|
job.workspaceId,
|
||||||
|
job.payload.store_id,
|
||||||
job.payload.platform_id,
|
job.payload.platform_id,
|
||||||
job.payload.category_id ?? '',
|
job.payload.category_id ?? '',
|
||||||
attributes,
|
attributes,
|
||||||
@@ -95,6 +104,8 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
|||||||
} else {
|
} else {
|
||||||
const dictionary = await requireDictionaryAttribute(
|
const dictionary = await requireDictionaryAttribute(
|
||||||
database,
|
database,
|
||||||
|
job.workspaceId,
|
||||||
|
job.payload.store_id,
|
||||||
job.payload.platform_id,
|
job.payload.platform_id,
|
||||||
job.payload.category_id,
|
job.payload.category_id,
|
||||||
job.payload.dictionary_id,
|
job.payload.dictionary_id,
|
||||||
@@ -111,7 +122,10 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
|||||||
);
|
);
|
||||||
result = await persistDictionaryValues(
|
result = await persistDictionaryValues(
|
||||||
database,
|
database,
|
||||||
|
job.workspaceId,
|
||||||
|
job.payload.store_id,
|
||||||
job.payload.platform_id,
|
job.payload.platform_id,
|
||||||
|
job.payload.category_id ?? '',
|
||||||
job.payload.dictionary_id ?? '',
|
job.payload.dictionary_id ?? '',
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
@@ -174,9 +188,9 @@ async function loadSource(database: Database, job: CatalogSyncJob) {
|
|||||||
JOIN atlas.platforms platform ON platform.id = store.platform_id AND platform.id = $3
|
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
|
JOIN atlas.store_secrets secret ON secret.id = $4 AND secret.store_id = store.id
|
||||||
WHERE operation.id = $1 AND operation.kind IN (
|
WHERE operation.id = $1 AND operation.kind IN (
|
||||||
'platform_category_tree_sync',
|
'store_category_tree_sync',
|
||||||
'platform_category_attribute_sync',
|
'store_category_attribute_sync',
|
||||||
'platform_attribute_dictionary_sync'
|
'store_attribute_dictionary_sync'
|
||||||
)
|
)
|
||||||
AND store.credential_status = 'valid'
|
AND store.credential_status = 'valid'
|
||||||
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||||
@@ -237,34 +251,38 @@ async function failOperation(
|
|||||||
|
|
||||||
async function requireCategory(
|
async function requireCategory(
|
||||||
database: Database,
|
database: Database,
|
||||||
|
workspaceId: string,
|
||||||
|
storeId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
categoryId: string | undefined,
|
categoryId: string | undefined,
|
||||||
) {
|
) {
|
||||||
if (!categoryId) throw new Error('Catalog sync category is missing');
|
if (!categoryId) throw new Error('Catalog sync category is missing');
|
||||||
const result = await database.query<CategorySource>(
|
const result = await database.workspaceTransaction(workspaceId, (tx) => tx.query<CategorySource>(
|
||||||
`SELECT external_category_id, external_type_id
|
`SELECT external_category_id, external_type_id
|
||||||
FROM atlas.platform_product_categories
|
FROM atlas.store_product_categories
|
||||||
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
WHERE id = $1 AND store_id = $2 AND platform_id = $3 AND is_active`,
|
||||||
[categoryId, platformId],
|
[categoryId, storeId, platformId],
|
||||||
);
|
));
|
||||||
if (!result.rows[0]) throw new Error('Catalog sync category no longer exists');
|
if (!result.rows[0]) throw new Error('Catalog sync category no longer exists');
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireDictionaryAttribute(
|
async function requireDictionaryAttribute(
|
||||||
database: Database,
|
database: Database,
|
||||||
|
workspaceId: string,
|
||||||
|
storeId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
categoryId: string | undefined,
|
categoryId: string | undefined,
|
||||||
dictionaryId: string | undefined,
|
dictionaryId: string | undefined,
|
||||||
) {
|
) {
|
||||||
if (!categoryId || !dictionaryId) throw new Error('Catalog dictionary input is missing');
|
if (!categoryId || !dictionaryId) throw new Error('Catalog dictionary input is missing');
|
||||||
const result = await database.query<{ platform_attribute_id: string }>(
|
const result = await database.workspaceTransaction(workspaceId, (tx) => tx.query<{ platform_attribute_id: string }>(
|
||||||
`SELECT platform_attribute_id
|
`SELECT platform_attribute_id
|
||||||
FROM atlas.platform_product_category_attributes
|
FROM atlas.store_product_category_attributes
|
||||||
WHERE platform_id = $1 AND platform_product_category_id = $2 AND dictionary_id = $3
|
WHERE store_id = $1 AND platform_id = $2 AND store_product_category_id = $3 AND dictionary_id = $4
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[platformId, categoryId, dictionaryId],
|
[storeId, platformId, categoryId, dictionaryId],
|
||||||
);
|
));
|
||||||
if (!result.rows[0]) throw new Error('Catalog dictionary is no longer valid for this category');
|
if (!result.rows[0]) throw new Error('Catalog dictionary is no longer valid for this category');
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
@@ -281,6 +299,8 @@ type FlatCategory = {
|
|||||||
|
|
||||||
async function persistCategoryTree(
|
async function persistCategoryTree(
|
||||||
database: Database,
|
database: Database,
|
||||||
|
workspaceId: string,
|
||||||
|
storeId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
roots: OzonCategoryNode[],
|
roots: OzonCategoryNode[],
|
||||||
) {
|
) {
|
||||||
@@ -295,25 +315,25 @@ async function persistCategoryTree(
|
|||||||
).values(),
|
).values(),
|
||||||
];
|
];
|
||||||
const syncTime = new Date();
|
const syncTime = new Date();
|
||||||
await database.transaction(async (tx) => {
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`WITH incoming AS (
|
`WITH incoming AS (
|
||||||
SELECT * FROM jsonb_to_recordset($2::jsonb) AS value(
|
SELECT * FROM jsonb_to_recordset($3::jsonb) AS value(
|
||||||
external_category_id text, external_type_id text, parent_key text,
|
external_category_id text, external_type_id text, parent_key text,
|
||||||
name text, path text, is_active boolean, source_payload jsonb
|
name text, path text, is_active boolean, source_payload jsonb
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
INSERT INTO atlas.platform_product_categories
|
INSERT INTO atlas.store_product_categories
|
||||||
(platform_id, external_category_id, external_type_id, name, path,
|
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path,
|
||||||
is_active, source_payload, synced_at)
|
is_active, source_payload, synced_at)
|
||||||
SELECT $1, external_category_id, external_type_id, name, path,
|
SELECT current_setting('app.workspace_id')::uuid, $1, $2, external_category_id, external_type_id, name, path,
|
||||||
is_active, source_payload, $3::timestamptz
|
is_active, source_payload, $4::timestamptz
|
||||||
FROM incoming
|
FROM incoming
|
||||||
ON CONFLICT (platform_id, external_category_id, external_type_id) DO UPDATE
|
ON CONFLICT (store_id, external_category_id, external_type_id) DO UPDATE
|
||||||
SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active,
|
SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active,
|
||||||
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
|
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
|
||||||
updated_at = now()`,
|
updated_at = now()`,
|
||||||
[platformId, JSON.stringify(records), syncTime],
|
[storeId, platformId, JSON.stringify(records), syncTime],
|
||||||
);
|
);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`WITH incoming AS (
|
`WITH incoming AS (
|
||||||
@@ -324,23 +344,23 @@ async function persistCategoryTree(
|
|||||||
), resolved AS (
|
), resolved AS (
|
||||||
SELECT child.id AS child_id, parent.id AS parent_id
|
SELECT child.id AS child_id, parent.id AS parent_id
|
||||||
FROM incoming
|
FROM incoming
|
||||||
JOIN atlas.platform_product_categories child
|
JOIN atlas.store_product_categories child
|
||||||
ON child.platform_id = $1
|
ON child.store_id = $1
|
||||||
AND child.external_category_id = incoming.external_category_id
|
AND child.external_category_id = incoming.external_category_id
|
||||||
AND child.external_type_id = incoming.external_type_id
|
AND child.external_type_id = incoming.external_type_id
|
||||||
LEFT JOIN atlas.platform_product_categories parent
|
LEFT JOIN atlas.store_product_categories parent
|
||||||
ON parent.platform_id = $1
|
ON parent.store_id = $1
|
||||||
AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key
|
AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key
|
||||||
)
|
)
|
||||||
UPDATE atlas.platform_product_categories category
|
UPDATE atlas.store_product_categories category
|
||||||
SET parent_id = resolved.parent_id, updated_at = now()
|
SET parent_id = resolved.parent_id, updated_at = now()
|
||||||
FROM resolved WHERE category.id = resolved.child_id`,
|
FROM resolved WHERE category.id = resolved.child_id`,
|
||||||
[platformId, JSON.stringify(records)],
|
[storeId, JSON.stringify(records)],
|
||||||
);
|
);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`UPDATE atlas.platform_product_categories SET is_active = false, updated_at = now()
|
`UPDATE atlas.store_product_categories SET is_active = false, updated_at = now()
|
||||||
WHERE platform_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
WHERE store_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
||||||
[platformId, syncTime],
|
[storeId, syncTime],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -396,6 +416,8 @@ type CachedAttribute = {
|
|||||||
|
|
||||||
async function persistAttributes(
|
async function persistAttributes(
|
||||||
database: Database,
|
database: Database,
|
||||||
|
workspaceId: string,
|
||||||
|
storeId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
attributes: OzonCategoryAttribute[],
|
attributes: OzonCategoryAttribute[],
|
||||||
@@ -403,27 +425,27 @@ async function persistAttributes(
|
|||||||
const records = attributes
|
const records = attributes
|
||||||
.map(toCachedAttribute)
|
.map(toCachedAttribute)
|
||||||
.filter((attribute): attribute is CachedAttribute => attribute !== null);
|
.filter((attribute): attribute is CachedAttribute => attribute !== null);
|
||||||
await database.transaction(async (tx) => {
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`DELETE FROM atlas.platform_product_category_attributes
|
`DELETE FROM atlas.store_product_category_attributes
|
||||||
WHERE platform_id = $1 AND platform_product_category_id = $2`,
|
WHERE store_id = $1 AND store_product_category_id = $2`,
|
||||||
[platformId, categoryId],
|
[storeId, categoryId],
|
||||||
);
|
);
|
||||||
if (!records.length) return;
|
if (!records.length) return;
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`INSERT INTO atlas.platform_product_category_attributes
|
`INSERT INTO atlas.store_product_category_attributes
|
||||||
(platform_id, platform_product_category_id, platform_attribute_id, complex_id,
|
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, complex_id,
|
||||||
name, value_type, is_required, is_collection, max_value_count,
|
name, value_type, is_required, is_collection, max_value_count,
|
||||||
dictionary_id, is_aspect, source_payload, synced_at)
|
dictionary_id, is_aspect, source_payload, synced_at)
|
||||||
SELECT $1, $2, platform_attribute_id, complex_id, name, value_type,
|
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, platform_attribute_id, complex_id, name, value_type,
|
||||||
is_required, is_collection, max_value_count, dictionary_id,
|
is_required, is_collection, max_value_count, dictionary_id,
|
||||||
is_aspect, source_payload, now()
|
is_aspect, source_payload, now()
|
||||||
FROM jsonb_to_recordset($3::jsonb) AS value(
|
FROM jsonb_to_recordset($4::jsonb) AS value(
|
||||||
platform_attribute_id text, complex_id text, name text, value_type text,
|
platform_attribute_id text, complex_id text, name text, value_type text,
|
||||||
is_required boolean, is_collection boolean, max_value_count integer,
|
is_required boolean, is_collection boolean, max_value_count integer,
|
||||||
dictionary_id text, is_aspect boolean, source_payload jsonb
|
dictionary_id text, is_aspect boolean, source_payload jsonb
|
||||||
)`,
|
)`,
|
||||||
[platformId, categoryId, JSON.stringify(records)],
|
[storeId, platformId, categoryId, JSON.stringify(records)],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return { attribute_count: records.length };
|
return { attribute_count: records.length };
|
||||||
@@ -469,7 +491,10 @@ function mapAttributeType(value: string | null | undefined): CachedAttribute['va
|
|||||||
|
|
||||||
async function persistDictionaryValues(
|
async function persistDictionaryValues(
|
||||||
database: Database,
|
database: Database,
|
||||||
|
workspaceId: string,
|
||||||
|
storeId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
|
categoryId: string,
|
||||||
dictionaryId: string,
|
dictionaryId: string,
|
||||||
values: OzonDictionaryValue[],
|
values: OzonDictionaryValue[],
|
||||||
) {
|
) {
|
||||||
@@ -483,17 +508,17 @@ async function persistDictionaryValues(
|
|||||||
!!value.external_value_id && !!value.value,
|
!!value.external_value_id && !!value.value,
|
||||||
);
|
);
|
||||||
if (!records.length) return { dictionary_value_count: 0 };
|
if (!records.length) return { dictionary_value_count: 0 };
|
||||||
await database.query(
|
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||||
`INSERT INTO atlas.platform_attribute_dictionary_values
|
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||||
(platform_id, dictionary_id, external_value_id, value, is_active, source_payload, synced_at)
|
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value, is_active, source_payload, synced_at)
|
||||||
SELECT $1, $2, external_value_id, value, true, source_payload, now()
|
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, external_value_id, value, true, source_payload, now()
|
||||||
FROM jsonb_to_recordset($3::jsonb) AS value(
|
FROM jsonb_to_recordset($5::jsonb) AS value(
|
||||||
external_value_id text, value text, source_payload jsonb
|
external_value_id text, value text, source_payload jsonb
|
||||||
)
|
)
|
||||||
ON CONFLICT (platform_id, dictionary_id, external_value_id) DO UPDATE
|
ON CONFLICT (store_id, store_product_category_id, dictionary_id, external_value_id) DO UPDATE
|
||||||
SET value = EXCLUDED.value, is_active = true, source_payload = EXCLUDED.source_payload,
|
SET value = EXCLUDED.value, is_active = true, source_payload = EXCLUDED.source_payload,
|
||||||
synced_at = EXCLUDED.synced_at, updated_at = now()`,
|
synced_at = EXCLUDED.synced_at, updated_at = now()`,
|
||||||
[platformId, dictionaryId, JSON.stringify(records)],
|
[storeId, platformId, categoryId, dictionaryId, JSON.stringify(records)],
|
||||||
);
|
));
|
||||||
return { dictionary_value_count: records.length };
|
return { dictionary_value_count: records.length };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 商品编辑与首次上架 API
|
# 商品编辑与首次上架 API
|
||||||
|
|
||||||
> 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 任务与发布仍是冻结契约,尚未实现。
|
> 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 在门店 Ozon 凭据验证成功后同步该门店的中文类目树,编辑器只读取当前目标门店缓存;属性和关键词字典仍按需同步。媒体选择、定价、AI 任务与发布仍是冻结契约,尚未实现。
|
||||||
|
|
||||||
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
|||||||
"default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" },
|
"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" } },
|
"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"],
|
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"],
|
||||||
"platform_product_category_id": "uuid", "category_source": "manual" },
|
"store_product_category_id": "uuid", "category_source": "manual" },
|
||||||
"variants": [{
|
"variants": [{
|
||||||
"id": "uuid-or-null", "client_key": "new-row-key", "copy_from_variant_id": null, "display_name": "黑色 / M", "source_sku": null,
|
"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",
|
"specification_values": [{ "axis_key": "color", "value_key": "black", "value_name": "黑色" }], "purchase_cost_cny": "80.00",
|
||||||
@@ -110,7 +110,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
|||||||
- `ai_source_material` 是客户端当前看到的完整有效素材,`ai_source_overridden_fields` 仅列出用户明确修改的顶层字段。服务端只持久化这些字段的覆盖值,其余字段继续跟随最新采集结果;恢复字段时将其从该数组移除即可。
|
- `ai_source_material` 是客户端当前看到的完整有效素材,`ai_source_overridden_fields` 仅列出用户明确修改的顶层字段。服务端只持久化这些字段的覆盖值,其余字段继续跟随最新采集结果;恢复字段时将其从该数组移除即可。
|
||||||
- 来源素材与商品/SKU 草稿在同一事务保存,受 `product.revision` 保护。`ai_source_material.source_snapshot_revision` 必须等于服务端当前采集版本;保存期间发生复采时返回 `409 SOURCE_SNAPSHOT_CHANGED`,客户端应重新读取合并结果并让用户确认,不能用旧页面覆盖新来源。
|
- 来源素材与商品/SKU 草稿在同一事务保存,受 `product.revision` 保护。`ai_source_material.source_snapshot_revision` 必须等于服务端当前采集版本;保存期间发生复采时返回 `409 SOURCE_SNAPSHOT_CHANGED`,客户端应重新读取合并结果并让用户确认,不能用旧页面覆盖新来源。
|
||||||
- 保存来源素材不会修改原始采集证据。`ai_source_overridden_fields` 只允许 `title`、`description`、`price`、`brand`、`attributes`、`categories`,且每项按完整顶层值覆盖,不做数组或对象内部的隐式合并。
|
- 保存来源素材不会修改原始采集证据。`ai_source_overridden_fields` 只允许 `title`、`description`、`price`、`brand`、`attributes`、`categories`,且每项按完整顶层值覆盖,不做数组或对象内部的隐式合并。
|
||||||
- `profile.platform_product_category_id` 必须是当前平台的活动类目;服务端从类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。`attributes` 是完整属性草稿集合,属性 ID、复杂属性 ID、值类型、单/多值上限和字典值均由当前类目 schema 校验。
|
- `profile.store_product_category_id` 必须是当前目标门店的活动类目;服务端从门店类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。`attributes` 是完整属性草稿集合,属性 ID、复杂属性 ID、值类型、单/多值上限和字典值均由当前门店类目 schema 校验。
|
||||||
- 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。
|
- 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。
|
||||||
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
|
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
|
||||||
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
|
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
|
||||||
@@ -138,19 +138,15 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
|
|||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}`
|
`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}`
|
||||||
|
|
||||||
返回当前平台最多 100 个活动类目,包含本地 UUID、平台外部类目/类型 ID、名称与路径。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。
|
返回当前平台的缓存类目树节点,包含本地 UUID、父节点 UUID、平台外部类目/类型 ID、名称、路径和活动状态。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。缓存为空时客户端显示“准备中”,不提供同步按钮;Worker 仅使用显式配置的共享 Ozon 缓存源预热该树。
|
||||||
|
|
||||||
`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}`
|
`POST /api/product-platform-profiles/{profile_id}/attribute-schema-syncs?category_id={uuid}`
|
||||||
|
|
||||||
为已选活动类目创建 `platform_category_attribute_sync` operation。同步以单类目事务替换 schema;下一次编辑草稿保存会依最新 schema 校验并将不匹配旧值转为未映射草稿。
|
为已选门店活动类目创建 `store_category_attribute_sync` operation。同步以单类目事务替换该门店 schema;下一次编辑草稿保存会依最新 schema 校验并将不匹配旧值转为未映射草稿。
|
||||||
|
|
||||||
`POST /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}/syncs?category_id={uuid}&q={keyword}`
|
`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 搜索”。
|
按关键词(至少两个字符)创建 `store_attribute_dictionary_sync` operation,Worker 使用该门店的 Ozon 字典搜索接口并 upsert 命中的本地值。不会为打开一个字段而全量下载大字典;前端先读本地缓存,缺少结果时由用户明确选择“从 Ozon 搜索”。
|
||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/validation`
|
`GET /api/product-platform-profiles/{profile_id}/validation`
|
||||||
|
|
||||||
|
|||||||
@@ -101,13 +101,13 @@
|
|||||||
④ 选中后保存 description_category_id + type_id
|
④ 选中后保存 description_category_id + type_id
|
||||||
```
|
```
|
||||||
|
|
||||||
### 场景 2:服务端缓存
|
### 场景 2:服务端门店缓存
|
||||||
|
|
||||||
```
|
```
|
||||||
① 启动时拉取类目树(language=DEFAULT,英文字段名便于代码处理)
|
① 门店 `Client-Id` + `Api-Key` 验证成功后拉取类目树(Atlas 使用 `ZH_HANS`)
|
||||||
② 存入 category_tree 表(见 docs/v2/database.md §2.7)
|
② 按门店存入本地类目缓存;不与其他门店混用
|
||||||
③ TTL 24h,过期重拉
|
③ 同步失败由后台任务重试,已验证的门店凭据不因此改为无效
|
||||||
④ 用户选类目时直接查库,不频繁调 API
|
④ 用户选类目时直接查当前目标门店缓存,不频繁调 API
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。
|
上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。
|
||||||
|
|
||||||
前端根据当前平台、本地同步的商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。类目列表为空时,用户可从编辑页发起一次 Ozon 类目树同步;选中类目但 schema 缺失时,显式同步该类目属性。字典控件先搜索本地缓存,输入至少两个字符仍无结果时,可显式从 Ozon 搜索并缓存命中值。`is_aspect=true` 在编辑器中按 SKU 展示,其余属性按商品展示;保存仍由服务端复核 schema 与字典值。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从服务端类目缓存取得。类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。
|
前端根据当前目标门店、本地同步的商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。门店 Ozon 凭据验证成功后,Worker 使用该门店凭据同步中文类目树;缓存为空时编辑器显示准备中状态,用户不能在编辑页发起树同步。选中类目但 schema 缺失时,仍可显式同步该类目属性。字典控件先搜索本地缓存,输入至少两个字符仍无结果时,可显式从 Ozon 搜索并缓存命中值。`is_aspect=true` 在编辑器中按 SKU 展示,其余属性按商品展示;保存仍由服务端复核 schema 与字典值。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从服务端门店类目缓存取得。类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。
|
||||||
|
|
||||||
## 3. SKU 卡片和列表
|
## 3. SKU 卡片和列表
|
||||||
|
|
||||||
|
|||||||
+37
-15
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
当前没有完成的是“可直接用于发布”的闭环:候选素材选择/物化、定价确认、AI 任务与候选结果、Ozon 首次发布、在线商品同步仍未开发。真实站点回归也尚未由本项目完成;用户已确认此前提出的首次回归项基本满足采集箱和商品编辑需求,但仍建议在最终发布前保留一轮样本验收。
|
当前没有完成的是“可直接用于发布”的闭环:候选素材选择/物化、定价确认、AI 任务与候选结果、Ozon 首次发布、在线商品同步仍未开发。真实站点回归也尚未由本项目完成;用户已确认此前提出的首次回归项基本满足采集箱和商品编辑需求,但仍建议在最终发布前保留一轮样本验收。
|
||||||
|
|
||||||
当前最高优先级是类目行为改造:旧代码仍允许用户手动同步 Ozon 类目,最新决定要求改为应用启动后同步一次并写入数据库,编辑器使用树形 Cascader 直接读取缓存;Ozon 采集到的类目还需要按层级路径自动回填,且不能覆盖用户已经保存的类目。该项目前处于代码改造中,未完成端到端验收,详见 [1.27](#127-当前进行中2026-09-10类目树交互改造尚未验收)。
|
当前最高优先级是完成真实样本回归,然后进入候选素材选择/物化这一条发布前纵向能力。类目树改造已落地:Worker 使用显式指定的 Ozon 缓存源预热共享树,编辑器直接读取 Cascader 树,并会在空类目时按 Ozon 来源层级回填;详见 [1.28](#128-开发进度2026-09-10类目树预热与来源回填)。
|
||||||
|
|
||||||
接手前请注意:工作区存在未提交的 M3 商品编辑、M3.4/M3.5 缓存、前端样式和迁移改动。不要用 `git reset --hard` 或批量清理未跟踪文件;先阅读 `git status --short`,按文件和迁移编号判断归属。当前中间代码的共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。
|
接手前请先阅读 `git status --short`;当前 M3 商品编辑和 M3.4/M3.5 缓存改动均已提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。
|
||||||
|
|
||||||
### 1.1 开发进度(2026-09-02)
|
### 1.1 开发进度(2026-09-02)
|
||||||
|
|
||||||
@@ -215,24 +215,47 @@
|
|||||||
|
|
||||||
### 1.25 开发进度(2026-09-10):M3.4 类目与动态属性
|
### 1.25 开发进度(2026-09-10):M3.4 类目与动态属性
|
||||||
|
|
||||||
- 新增迁移 `021_product_category_attribute_schema.sql`,建立平台级的类目属性 schema 与字典值缓存表。缓存记录属性值类型、必填、多值上限、字典标识及 `is_aspect`;它们不是工作空间草稿,商品属性只能引用当前平台/类目的有效 schema。
|
- 旧迁移 `021_product_category_attribute_schema.sql` 建立了平台级 schema 缓存;最新设计由 `022_store_catalog_cache.sql` 另建门店级类目、schema 与字典缓存,发布草稿只引用门店级数据。
|
||||||
- 新增编辑器接口:类目候选、类目属性 schema 与属性字典值。类目 schema/字典接口支持传入未保存的 `category_id` 预览,因此用户选择新类目后不必先保存一次才能填写字段。
|
- 新增编辑器接口:类目候选、类目属性 schema 与属性字典值。类目 schema/字典接口支持传入未保存的 `category_id` 预览,因此用户选择新类目后不必先保存一次才能填写字段。
|
||||||
- 编辑器草稿可原子保存 `platform_product_category_id`、类目来源与商品/SKU 属性;服务端派生 `platform_type_id`,复核类型、值数量、字典值与 SKU 归属。类目变更或 schema 下线的旧属性会转入 `product_unmapped_attributes`,不会默默丢失或进入新的发布 payload。
|
- 编辑器草稿可原子保存 `store_product_category_id`、类目来源与商品/SKU 属性;服务端派生 `platform_type_id`,复核类型、值数量、字典值与 SKU 归属。类目变更或 schema 下线的旧属性会转入 `product_unmapped_attributes`,不会默默丢失或进入新的发布 payload。
|
||||||
- Web 编辑页已提供类目选择、商品/SKU 维度动态字段、字典选择、未映射属性告警;上架店铺/主题标签保持标准 40px 控件高度,父商品包装重量与三项包装尺寸宽度分别为 150px / 120px。
|
- Web 编辑页已提供类目选择、商品/SKU 维度动态字段、字典选择、未映射属性告警;上架店铺/主题标签保持标准 40px 控件高度,父商品包装重量与三项包装尺寸宽度分别为 150px / 120px。
|
||||||
|
|
||||||
### 1.26 开发进度(2026-09-10):M3.5 Ozon 类目与属性缓存同步
|
### 1.26 开发进度(2026-09-10):M3.5 Ozon 类目与属性缓存同步
|
||||||
|
|
||||||
- 已实现类目树、类目属性 schema、字典值缓存的数据表、Worker 同步逻辑、Transactional Outbox 和读 API;缓存属于平台级元数据,不属于某个商品草稿。浏览器、operation 响应与 outbox 事件都不含 API Key。
|
- 已实现类目树、类目属性 schema、字典值缓存的数据表、Worker 同步逻辑、Transactional Outbox 和读 API;缓存属于门店级发布元数据,不属于某个商品草稿。浏览器、operation 响应与 outbox 事件都不含 API Key。
|
||||||
- 当前旧实现仍把“同步 Ozon 类目”暴露为商品编辑页操作;这已被最新产品决策否定,不能作为完成状态使用。属性 schema 仍是按类目读取/按需同步的现状,后续需单独确认是否也改为启动预热。
|
- 当前旧实现仍把“同步 Ozon 类目”暴露为商品编辑页操作;这已被最新产品决策否定,不能作为完成状态使用。属性 schema 仍是按类目读取/按需同步的现状,后续需单独确认是否也改为启动预热。
|
||||||
- 字典不会全量拉取:字段先读取已有缓存;用户输入至少两个字符且本地无匹配时,现有实现可显式发起 Ozon `/attribute/values/search`,Worker 仅 upsert 返回的命中项。同步失败应保留旧缓存和可读错误状态。
|
- 字典不会全量拉取:字段先读取已有缓存;用户输入至少两个字符且本地无匹配时,现有实现可显式发起 Ozon `/attribute/values/search`,Worker 仅 upsert 返回的命中项。同步失败应保留旧缓存和可读错误状态。
|
||||||
|
|
||||||
### 1.27 当前进行中(2026-09-10):类目树交互改造,尚未验收
|
### 1.27 历史状态(2026-09-10):平台级预热方案已废弃
|
||||||
|
|
||||||
- 最新产品要求:应用启动后为 Ozon 执行一次类目树同步并持久化到 `platform_product_categories`;商品编辑页不再出现“同步/刷新类目”按钮,打开“产品类目”时直接从数据库读取并展示树形层级。
|
- 原交接要求的“Worker 启动时以指定门店预热一份平台共享类目树”已被产品决策替代:类目、属性和字典缓存按门店隔离,不能再恢复平台级共享缓存或环境变量指定的共享源。
|
||||||
- 当前代码已开始把类目候选扩展为 `parent_id`、`is_active` 并将前端控件改为 Cascader,但仍需完成启动同步触发、旧手动同步入口清理、树形数据与表单值的最终对接,以及真实页面回归。不要把这部分当作 M3.5 已完成。
|
- 保留的原则是:浏览器、日志、operation 和 outbox 事件均不携带 API Key;编辑器只读缓存,不提供类目树手动同步入口。
|
||||||
- Ozon 采集器现在保存 BreadcrumbList/可见面包屑的名称与 URL 证据,但 `common.categories` 尚未保存可直接关联缓存节点的稳定 Ozon 类目 ID。需要在编辑上下文创建/读取时按规范化层级路径匹配缓存树,并且只在 profile 尚无用户选择时回填 `platform_product_category_id`,来源标记为 `source_capture`;用户手工选择或已保存类目不能被复采覆盖。
|
|
||||||
- 类目树同步应使用一个已验证的 Ozon 店铺凭据作为平台共享缓存来源,任务去重、失败重试和并发启动保护必须明确;不能为每个商品或每次打开编辑器重复请求 Ozon。类目缓存为空时页面应显示“缓存准备中/暂无可用类目”,而不是让用户发起同步。
|
### 1.28 开发进度(2026-09-10):门店类目同步与来源回填
|
||||||
- 当前中间代码的 TypeScript 检查需要先构建共享 contracts,再检查 Web/API/Worker;已验证命令为:`tsc -p packages/contracts/tsconfig.json`、随后分别执行各应用 `tsc --noEmit`。启动同步与类目自动回填尚未有集成测试。
|
|
||||||
|
- Ozon 门店凭据验证成功后,在同一事务中创建门店类目同步 operation 与 outbox 事件;Worker 使用该门店 `Client-Id + Api-Key` 请求中文类目树,并写入 `store_product_categories`。失败只标记类目同步 operation,不会把已验证的凭据改为无效。
|
||||||
|
- 已删除编辑器的手动类目树同步接口和客户端调用。属性 schema 与字典值仍按当前类目和关键词按需同步,使用原有 Outbox/Worker 链路。
|
||||||
|
- Cascader 显示完整缓存树路径,但表单与草稿 API 始终保存末级类目 UUID,修复了路径数组不能直接提交到 `editor-draft` 的问题。
|
||||||
|
- 对 Ozon 来源商品,进入或读取编辑器时会将 `common.categories` 的规范化层级路径与活动缓存节点精确匹配;仅当 profile 未保存类目时写入匹配项并标记 `source_capture`。已有手工选择或此前保存的类目永远不被复采覆盖。API 集成测试覆盖回填和保护规则。
|
||||||
|
- 已新增 API 集成测试覆盖来源回填和保护规则;仍需在有效 Ozon 门店、真实 Ozon 样本与浏览器页面上执行最终回归。本地 Docker 基础设施未启动时,该集成测试无法实际执行。
|
||||||
|
|
||||||
|
### 1.29 当前完成情况与下一步(2026-09-10)
|
||||||
|
|
||||||
|
已完成:
|
||||||
|
|
||||||
|
- Cascader 使用完整路径显示、末级 UUID 保存;初始回显、手动切换和服务端门店归属校验均已接入。切换目标门店会清空旧门店类目及动态属性,再加载新门店缓存。
|
||||||
|
- 门店验证成功后在同一事务创建 `store_category_tree_sync` operation/outbox;Worker 用该门店 `Client-Id + Api-Key` 和 `ZH_HANS` 拉取树。BullMQ 任务使用 5 次指数退避重试;同步失败写 operation,不使已验证门店变为无效。
|
||||||
|
- 编辑器旧手动类目树同步接口及客户端调用已删除;来源面包屑自动匹配只在空类目时写入 `source_capture`,不会覆盖手选或已保存选择。
|
||||||
|
- Contracts、API、Worker、Web TypeScript 检查和 Web 生产构建已通过;`git diff --check` 已通过。
|
||||||
|
|
||||||
|
未完成 / 下一步:
|
||||||
|
|
||||||
|
1. 为 `store_category_tree_sync` 增加门店级去重:已有有效缓存跳过;同一门店的并发验证/重复事件只允许一次实际 Ozon 请求。
|
||||||
|
2. 新增 Worker 集成测试,覆盖空缓存首次同步、缓存跳过、并发互斥、Ozon 失败后的指数重试和 API Key 不进入队列/日志/数据库。
|
||||||
|
3. 启动本地 PostgreSQL 与 Redis,执行 `022_store_catalog_cache.sql`,运行 API 集成测试并检查 RLS、外键和历史 profile 兼容性。
|
||||||
|
4. 使用真实已验证 Ozon 门店和样本,在 `http://localhost:8100` 回归:类目树回显、切换店铺、保存 UUID、来源回填、不覆盖手选类目以及属性/字典按需同步。
|
||||||
|
|
||||||
|
当前阻塞:本机 PostgreSQL 与 Redis 未运行,`apps/api/test/product-editor.e2e.test.ts` 连接报 `ECONNREFUSED`,因此 API 集成测试和迁移尚未实际验证。
|
||||||
|
|
||||||
## 2. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
@@ -298,10 +321,9 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
当前执行顺序:
|
当前执行顺序:
|
||||||
|
|
||||||
1. 实现 Worker 启动时一次性、幂等预热 Ozon 类目树;移除编辑器手动同步 UI;Cascader 直接读取数据库树。
|
1. 配置专用 Ozon 类目缓存源,在真实 Ozon 样本和 8100 页面完成类目预热、树形选择及来源回填回归。
|
||||||
2. 在创建/读取商品编辑上下文时,用来源面包屑层级与缓存 `path` 精确匹配 Ozon 类目;仅在 profile 没有用户已选类目时回填,并标记 `source_capture`。
|
2. 完成候选媒体选择与按需物化,并把已选素材纳入 SKU 编辑草稿。
|
||||||
3. 为启动同步、空缓存、重复启动、失败重试、树形选择和自动回填补 API/Worker 集成测试;重新执行 contracts、API、Worker、Web typecheck、Web build 与真实 8100 页面回归。
|
3. 完成定价、AI 任务与候选结果、首次发布和在线商品同步的剩余纵向闭环。
|
||||||
4. 完成动态属性 schema/字典缓存、候选媒体、定价、AI 任务、首次发布和在线商品同步的剩余纵向闭环。
|
|
||||||
|
|
||||||
### 历史计划(仅供追溯)
|
### 历史计划(仅供追溯)
|
||||||
|
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ export const saveProductEditorDraftRequestSchema = z
|
|||||||
title: nullableText(1_000),
|
title: nullableText(1_000),
|
||||||
description: nullableText(100_000),
|
description: nullableText(100_000),
|
||||||
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
||||||
platform_product_category_id: uuid.nullable().optional(),
|
store_product_category_id: uuid.nullable().optional(),
|
||||||
category_source: z
|
category_source: z
|
||||||
.enum(['source_capture', 'ai_suggestion', 'manual'])
|
.enum(['source_capture', 'ai_suggestion', 'manual'])
|
||||||
.nullable()
|
.nullable()
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- Ozon exposes catalog metadata through a seller credential. Keep the cache
|
||||||
|
-- scoped to that store so a region/account-specific response never leaks into
|
||||||
|
-- another store's publishing flow. The older platform-wide tables remain for
|
||||||
|
-- historical commission data and are intentionally not repurposed here.
|
||||||
|
CREATE TABLE store_product_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
store_id UUID NOT NULL,
|
||||||
|
platform_id UUID NOT NULL,
|
||||||
|
parent_id UUID REFERENCES store_product_categories(id) ON DELETE RESTRICT,
|
||||||
|
external_category_id TEXT NOT NULL,
|
||||||
|
external_type_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
path 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(),
|
||||||
|
FOREIGN KEY (workspace_id, store_id, platform_id)
|
||||||
|
REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (workspace_id, id),
|
||||||
|
UNIQUE (workspace_id, store_id, id),
|
||||||
|
UNIQUE (store_id, external_category_id, external_type_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_store_product_categories_tree
|
||||||
|
ON store_product_categories (workspace_id, store_id, parent_id, path, id);
|
||||||
|
CREATE INDEX ix_store_product_categories_active
|
||||||
|
ON store_product_categories (workspace_id, store_id, is_active, path, id);
|
||||||
|
|
||||||
|
CREATE TABLE store_product_category_attributes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
store_id UUID NOT NULL,
|
||||||
|
platform_id UUID NOT NULL,
|
||||||
|
store_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 (workspace_id, store_id, platform_id)
|
||||||
|
REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, store_id, store_product_category_id)
|
||||||
|
REFERENCES store_product_categories(workspace_id, store_id, id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (store_id, store_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_store_product_category_attributes_lookup
|
||||||
|
ON store_product_category_attributes (workspace_id, store_id, store_product_category_id, is_required DESC, name, id);
|
||||||
|
|
||||||
|
CREATE TABLE store_attribute_dictionary_values (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
store_id UUID NOT NULL,
|
||||||
|
platform_id UUID NOT NULL,
|
||||||
|
store_product_category_id UUID NOT NULL,
|
||||||
|
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(),
|
||||||
|
FOREIGN KEY (workspace_id, store_id, platform_id)
|
||||||
|
REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, store_id, store_product_category_id)
|
||||||
|
REFERENCES store_product_categories(workspace_id, store_id, id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (store_id, store_product_category_id, dictionary_id, external_value_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_store_attribute_dictionary_values_search
|
||||||
|
ON store_attribute_dictionary_values (workspace_id, store_id, store_product_category_id, dictionary_id, is_active, value, external_value_id);
|
||||||
|
|
||||||
|
ALTER TABLE product_platform_profiles
|
||||||
|
ADD COLUMN store_product_category_id UUID,
|
||||||
|
ADD CONSTRAINT fk_product_platform_profiles_store_category
|
||||||
|
FOREIGN KEY (workspace_id, target_store_id, store_product_category_id)
|
||||||
|
REFERENCES store_product_categories(workspace_id, store_id, id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE product_unmapped_attributes
|
||||||
|
ADD COLUMN original_store_product_category_id UUID,
|
||||||
|
ADD CONSTRAINT fk_product_unmapped_attributes_store_category
|
||||||
|
FOREIGN KEY (workspace_id, original_store_product_category_id)
|
||||||
|
REFERENCES store_product_categories(workspace_id, id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE table_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOREACH table_name IN ARRAY ARRAY[
|
||||||
|
'store_product_categories',
|
||||||
|
'store_product_category_attributes',
|
||||||
|
'store_attribute_dictionary_values'
|
||||||
|
] LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
|
||||||
|
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
|
||||||
|
EXECUTE format('CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
Reference in New Issue
Block a user